feat: add slash commands (#1461)

This commit is contained in:
Ralph Khreish
2025-12-13 18:53:14 +01:00
committed by GitHub
parent 889d1f03ad
commit 9ee63e01db
101 changed files with 13425 additions and 313 deletions

View File

@@ -0,0 +1,17 @@
---
"task-master-ai": minor
---
Add operating mode filtering for slash commands and rules
Solo mode and team mode now have distinct sets of commands and rules:
- **Solo mode**: Local file-based storage commands (parse-prd, add-task, expand, etc.) plus common commands
- **Team mode**: Team-specific commands (goham) plus common commands (show-task, list-tasks, help, etc.)
Both modes share common commands for viewing and navigating tasks. The difference is:
- Solo users get commands for local file management (PRD parsing, task expansion, dependencies)
- Team users get Hamster cloud integration commands instead
When switching modes (e.g., from solo to team), all existing TaskMaster commands and rules are automatically cleaned up before adding the new mode's files. This prevents orphaned commands/rules from previous modes.
The operating mode is auto-detected from config or auth status, and can be overridden with `--mode=solo|team` flag on the `rules` command.

View File

@@ -0,0 +1,13 @@
---
"task-master-ai": minor
---
Add Taskmaster slash commands for:
- Roo
- Cursor
- Codex
- Gemini
- Opencode
Add them with `task-master rules add <provider>`

View File

@@ -18,24 +18,54 @@ permissions:
env: env:
DO_NOT_TRACK: 1 DO_NOT_TRACK: 1
NODE_ENV: development NODE_ENV: development
NODE_VERSION: 20
jobs: jobs:
# Single install job that caches node_modules for all other jobs
install:
name: Install Dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
timeout-minutes: 5
# Fast checks that can run in parallel # Fast checks that can run in parallel
format-check: format-check:
name: Format Check name: Format Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: install
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies - name: Install dependencies
run: npm install --frozen-lockfile --prefer-offline if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
timeout-minutes: 5 timeout-minutes: 5
- name: Format Check - name: Format Check
@@ -62,7 +92,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: steps.changes.outputs.changesets == 'true' if: steps.changes.outputs.changesets == 'true'
with: with:
node-version: 20 node-version: ${{ env.NODE_VERSION }}
cache: "npm" cache: "npm"
- name: Validate changeset package references - name: Validate changeset package references
@@ -78,18 +108,24 @@ jobs:
name: Typecheck name: Typecheck
timeout-minutes: 10 timeout-minutes: 10
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: install
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies - name: Install dependencies
run: npm install --frozen-lockfile --prefer-offline if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
timeout-minutes: 5 timeout-minutes: 5
- name: Typecheck - name: Typecheck
@@ -101,18 +137,24 @@ jobs:
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: install
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies - name: Install dependencies
run: npm install --frozen-lockfile --prefer-offline if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
timeout-minutes: 5 timeout-minutes: 5
- name: Build - name: Build
@@ -139,16 +181,21 @@ jobs:
if: always() && !cancelled() && !contains(needs.*.result, 'failure') if: always() && !cancelled() && !contains(needs.*.result, 'failure')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies - name: Install dependencies
run: npm install --frozen-lockfile --prefer-offline if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
timeout-minutes: 5 timeout-minutes: 5
- name: Download build artifacts - name: Download build artifacts

View File

@@ -148,9 +148,7 @@ export class MCPClientManager {
version: '1.0.0' version: '1.0.0'
}, },
{ {
capabilities: { capabilities: {}
tools: {}
}
} }
); );

View File

@@ -72,7 +72,11 @@ describe('generate MCP tool', () => {
const output = execSync( const output = execSync(
`npx @modelcontextprotocol/inspector --cli node "${mcpServerPath}" --method tools/call --tool-name ${toolName} ${toolArgs}`, `npx @modelcontextprotocol/inspector --cli node "${mcpServerPath}" --method tools/call --tool-name ${toolName} ${toolArgs}`,
{ encoding: 'utf-8', stdio: 'pipe' } {
encoding: 'utf-8',
stdio: 'pipe',
env: { ...process.env, TASK_MASTER_TOOLS: 'all' }
}
); );
// Parse the MCP protocol response: { content: [{ type: "text", text: "<json>" }] } // Parse the MCP protocol response: { content: [{ type: "text", text: "<json>" }] }

36
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.37.2", "version": "0.38.0-rc.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.37.2", "version": "0.38.0-rc.0",
"license": "MIT WITH Commons-Clause", "license": "MIT WITH Commons-Clause",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -204,7 +204,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"task-master-ai": "*", "task-master-ai": "0.38.0-rc.0",
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"engines": { "engines": {
@@ -13432,6 +13432,10 @@
"resolved": "apps/mcp", "resolved": "apps/mcp",
"link": true "link": true
}, },
"node_modules/@tm/profiles": {
"resolved": "packages/tm-profiles",
"link": true
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -36630,6 +36634,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"packages/tm-profiles": {
"name": "@tm/profiles",
"devDependencies": {
"@types/node": "^22.10.5",
"@vitest/coverage-v8": "^4.0.10",
"typescript": "^5.9.2",
"vitest": "^4.0.10"
}
},
"packages/tm-profiles/node_modules/@types/node": {
"version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/tm-profiles/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
} }
} }
} }

View File

@@ -67,7 +67,7 @@ listTasks(tasksPath, statusFilter, reportPath, withSubtasks, outputFormat, conte
const tmCore = createTaskMasterCore(projectPath, { const tmCore = createTaskMasterCore(projectPath, {
storage: { storage: {
type: 'api', // or 'file' type: 'api', // or 'file'
apiEndpoint: 'https://hamster.ai/api', apiEndpoint: 'https://tryhamster.com',
apiAccessToken: 'xxx' apiAccessToken: 'xxx'
} }
}); });

View File

@@ -0,0 +1,33 @@
{
"name": "@tm/profiles",
"private": true,
"description": "Editor profile management for Task Master - handles commands and rules across different editors",
"type": "module",
"types": "./src/index.ts",
"main": "./dist/index.js",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "biome check --write",
"lint:check": "biome check",
"lint:fix": "biome check --fix --unsafe",
"format": "biome format --write",
"format:check": "biome format",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.10.5",
"@vitest/coverage-v8": "^4.0.10",
"typescript": "^5.9.2",
"vitest": "^4.0.10"
},
"files": ["src", "README.md", "CHANGELOG.md"],
"keywords": ["task-master", "profiles", "editor", "commands", "typescript"],
"author": "Task Master AI",
"version": ""
}

View File

@@ -0,0 +1,7 @@
/**
* @fileoverview TaskMaster Profiles Package
* Provides slash commands and formatters for different editor profiles.
*/
// Re-export everything from slash-commands module
export * from './slash-commands/index.js';

View File

@@ -0,0 +1,114 @@
/**
* @fileoverview Analyze Project Slash Command
* Advanced project analysis with actionable insights and recommendations.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The analyze-project slash command - Analyze Project
*
* Advanced project analysis with actionable insights and recommendations.
*/
export const analyzeProject = dynamicCommand(
'analyze-project',
'Analyze Project',
'[focus-area]',
`Advanced project analysis with actionable insights and recommendations.
Arguments: $ARGUMENTS
## Comprehensive Project Analysis
Multi-dimensional analysis based on requested focus area.
### 1. **Analysis Modes**
Based on $ARGUMENTS:
- "velocity" → Sprint velocity and trends
- "quality" → Code quality metrics
- "risk" → Risk assessment and mitigation
- "dependencies" → Dependency graph analysis
- "team" → Workload and skill distribution
- "architecture" → System design coherence
- Default → Full spectrum analysis
### 2. **Velocity Analytics**
\`\`\`
📊 Velocity Analysis
━━━━━━━━━━━━━━━━━━━
Current Sprint: 24 points/week ↗️ +20%
Rolling Average: 20 points/week
Efficiency: 85% (17/20 tasks on time)
Bottlenecks Detected:
- Code review delays (avg 4h wait)
- Test environment availability
- Dependency on external team
Recommendations:
1. Implement parallel review process
2. Add staging environment
3. Mock external dependencies
\`\`\`
### 3. **Risk Assessment**
**Technical Risks**
- High complexity tasks without backup assignee
- Single points of failure in architecture
- Insufficient test coverage in critical paths
- Technical debt accumulation rate
**Project Risks**
- Critical path dependencies
- Resource availability gaps
- Deadline feasibility analysis
- Scope creep indicators
### 4. **Dependency Intelligence**
Visual dependency analysis:
\`\`\`
Critical Path:
#12 → #15 → #23 → #45 → #50 (20 days)
↘ #24 → #46 ↗
Optimization: Parallelize #15 and #24
Time Saved: 3 days
\`\`\`
### 5. **Quality Metrics**
**Code Quality**
- Test coverage trends
- Complexity scores
- Technical debt ratio
- Review feedback patterns
**Process Quality**
- Rework frequency
- Bug introduction rate
- Time to resolution
- Knowledge distribution
### 6. **Predictive Insights**
Based on patterns:
- Completion probability by deadline
- Resource needs projection
- Risk materialization likelihood
- Suggested interventions
### 7. **Executive Dashboard**
High-level summary with:
- Health score (0-100)
- Top 3 risks
- Top 3 opportunities
- Recommended actions
- Success probability
Result: Data-driven decisions with clear action paths.`
);

View File

@@ -0,0 +1,114 @@
/**
* @fileoverview Auto Implement Tasks Slash Command
* Enhanced auto-implementation with intelligent code generation and testing.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The auto-implement-tasks slash command - Auto Implement Tasks
*
* Enhanced auto-implementation with intelligent code generation and testing.
*/
export const autoImplementTasks = dynamicCommand(
'auto-implement-tasks',
'Auto Implement Tasks',
'[task-id]',
`Enhanced auto-implementation with intelligent code generation and testing.
Arguments: $ARGUMENTS
## Intelligent Auto-Implementation
Advanced implementation with context awareness and quality checks.
### 1. **Pre-Implementation Analysis**
Before starting:
- Analyze task complexity and requirements
- Check codebase patterns and conventions
- Identify similar completed tasks
- Assess test coverage needs
- Detect potential risks
### 2. **Smart Implementation Strategy**
Based on task type and context:
**Feature Tasks**
1. Research existing patterns
2. Design component architecture
3. Implement with tests
4. Integrate with system
5. Update documentation
**Bug Fix Tasks**
1. Reproduce issue
2. Identify root cause
3. Implement minimal fix
4. Add regression tests
5. Verify side effects
**Refactoring Tasks**
1. Analyze current structure
2. Plan incremental changes
3. Maintain test coverage
4. Refactor step-by-step
5. Verify behavior unchanged
### 3. **Code Intelligence**
**Pattern Recognition**
- Learn from existing code
- Follow team conventions
- Use preferred libraries
- Match style guidelines
**Test-Driven Approach**
- Write tests first when possible
- Ensure comprehensive coverage
- Include edge cases
- Performance considerations
### 4. **Progressive Implementation**
Step-by-step with validation:
\`\`\`
Step 1/5: Setting up component structure ✓
Step 2/5: Implementing core logic ✓
Step 3/5: Adding error handling ⚡ (in progress)
Step 4/5: Writing tests ⏳
Step 5/5: Integration testing ⏳
Current: Adding try-catch blocks and validation...
\`\`\`
### 5. **Quality Assurance**
Automated checks:
- Linting and formatting
- Test execution
- Type checking
- Dependency validation
- Performance analysis
### 6. **Smart Recovery**
If issues arise:
- Diagnostic analysis
- Suggestion generation
- Fallback strategies
- Manual intervention points
- Learning from failures
### 7. **Post-Implementation**
After completion:
- Generate PR description
- Update documentation
- Log lessons learned
- Suggest follow-up tasks
- Update task relationships
Result: High-quality, production-ready implementations.`
);

View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Command Pipeline Slash Command
* Execute a pipeline of commands based on a specification.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The command-pipeline slash command - Command Pipeline
*
* Execute a pipeline of commands based on a specification.
*/
export const commandPipeline = dynamicCommand(
'command-pipeline',
'Command Pipeline',
'<pipeline-spec>',
`Execute a pipeline of commands based on a specification.
Arguments: $ARGUMENTS
## Command Pipeline Execution
Parse pipeline specification from arguments. Supported formats:
### Simple Pipeline
\`init → expand-all → sprint-plan\`
### Conditional Pipeline
\`status → if:pending>10 → sprint-plan → else → next\`
### Iterative Pipeline
\`for:pending-tasks → expand → complexity-check\`
### Smart Pipeline Patterns
**1. Project Setup Pipeline**
\`\`\`
init [prd] →
expand-all →
complexity-report →
sprint-plan →
show first-sprint
\`\`\`
**2. Daily Work Pipeline**
\`\`\`
standup →
if:in-progress → continue →
else → next → start
\`\`\`
**3. Task Completion Pipeline**
\`\`\`
complete [id] →
git-commit →
if:blocked-tasks-freed → show-freed →
next
\`\`\`
**4. Quality Check Pipeline**
\`\`\`
list in-progress →
for:each → check-idle-time →
if:idle>1day → prompt-update
\`\`\`
### Pipeline Features
**Variables**
- Store results: \`status → $count=pending-count\`
- Use in conditions: \`if:$count>10\`
- Pass between commands: \`expand $high-priority-tasks\`
**Error Handling**
- On failure: \`try:complete → catch:show-blockers\`
- Skip on error: \`optional:test-run\`
- Retry logic: \`retry:3:commit\`
**Parallel Execution**
- Parallel branches: \`[analyze | test | lint]\`
- Join results: \`parallel → join:report\`
### Execution Flow
1. Parse pipeline specification
2. Validate command sequence
3. Execute with state passing
4. Handle conditions and loops
5. Aggregate results
6. Show summary
This enables complex workflows like:
\`parse-prd → expand-all → filter:complex>70 → assign:senior → sprint-plan:weighted\``
);

View File

@@ -0,0 +1,115 @@
/**
* @fileoverview Help Slash Command
* Show help for Task Master AI commands.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The help slash command - Help
*
* Show help for Task Master AI commands.
*/
export const help = dynamicCommand(
'help',
'Help',
'[command-name]',
`Show help for Task Master AI commands.
Arguments: $ARGUMENTS
Display help for Task Master commands and available options.
## Task Master AI Command Help
### Quick Navigation
Type \`/taskmaster:\` and use tab completion to explore all commands.
### Command Categories
#### 🚀 Setup & Installation
- \`/taskmaster:install-taskmaster\` - Comprehensive installation guide
- \`/taskmaster:quick-install-taskmaster\` - One-line global install
#### 📋 Project Setup
- \`/taskmaster:init-project\` - Initialize new project
- \`/taskmaster:init-project-quick\` - Quick setup with auto-confirm
- \`/taskmaster:view-models\` - View AI configuration
- \`/taskmaster:setup-models\` - Configure AI providers
#### 🎯 Task Generation
- \`/taskmaster:parse-prd\` - Generate tasks from PRD
- \`/taskmaster:parse-prd-with-research\` - Enhanced parsing
- \`/taskmaster:generate-tasks\` - Create task files
#### 📝 Task Management
- \`/taskmaster:list-tasks\` - List all tasks
- \`/taskmaster:list-tasks-by-status\` - List tasks filtered by status
- \`/taskmaster:list-tasks-with-subtasks\` - List tasks with subtasks
- \`/taskmaster:show-task\` - Display task details
- \`/taskmaster:add-task\` - Create new task
- \`/taskmaster:update-task\` - Update single task
- \`/taskmaster:update-tasks-from-id\` - Update multiple tasks
- \`/taskmaster:next-task\` - Get next task recommendation
#### 🔄 Status Management
- \`/taskmaster:to-pending\` - Set task to pending
- \`/taskmaster:to-in-progress\` - Set task to in-progress
- \`/taskmaster:to-done\` - Set task to done
- \`/taskmaster:to-review\` - Set task to review
- \`/taskmaster:to-deferred\` - Set task to deferred
- \`/taskmaster:to-cancelled\` - Set task to cancelled
#### 🔍 Analysis & Breakdown
- \`/taskmaster:analyze-complexity\` - Analyze task complexity
- \`/taskmaster:complexity-report\` - View complexity report
- \`/taskmaster:expand-task\` - Break down complex task
- \`/taskmaster:expand-all-tasks\` - Expand all eligible tasks
#### 🔗 Dependencies
- \`/taskmaster:add-dependency\` - Add task dependency
- \`/taskmaster:remove-dependency\` - Remove dependency
- \`/taskmaster:validate-dependencies\` - Check for issues
- \`/taskmaster:fix-dependencies\` - Auto-fix dependency issues
#### 📦 Subtasks
- \`/taskmaster:add-subtask\` - Add subtask to task
- \`/taskmaster:convert-task-to-subtask\` - Convert task to subtask
- \`/taskmaster:remove-subtask\` - Remove subtask
- \`/taskmaster:remove-subtasks\` - Clear specific task subtasks
- \`/taskmaster:remove-all-subtasks\` - Clear all subtasks
#### 🗑️ Task Removal
- \`/taskmaster:remove-task\` - Remove task permanently
#### 🤖 Workflows
- \`/taskmaster:smart-workflow\` - Intelligent workflows
- \`/taskmaster:command-pipeline\` - Command chaining
- \`/taskmaster:auto-implement-tasks\` - Auto-implementation
#### 📊 Utilities
- \`/taskmaster:analyze-project\` - Project analysis
- \`/taskmaster:project-status\` - Project dashboard
- \`/taskmaster:sync-readme\` - Sync README with tasks
- \`/taskmaster:learn\` - Interactive learning
- \`/taskmaster:tm-main\` - Main Task Master interface
### Quick Start Examples
\`\`\`
/taskmaster:list-tasks
/taskmaster:show-task 1.2
/taskmaster:add-task
/taskmaster:next-task
\`\`\`
### Getting Started
1. Install: \`/taskmaster:quick-install-taskmaster\`
2. Initialize: \`/taskmaster:init-project-quick\`
3. Learn: \`/taskmaster:learn\`
4. Work: \`/taskmaster:smart-workflow\`
For detailed command info, run the specific command with \`--help\` or check command documentation.`
);

View File

@@ -0,0 +1,36 @@
/**
* @fileoverview Common Commands
* Commands that work in both solo and team modes.
*/
// Display
export { showTask } from './show-task.js';
export { listTasks } from './list-tasks.js';
export { listTasksWithSubtasks } from './list-tasks-with-subtasks.js';
export { listTasksByStatus } from './list-tasks-by-status.js';
export { projectStatus } from './project-status.js';
// Navigation
export { nextTask } from './next-task.js';
export { help } from './help.js';
// Status (common)
export { toDone } from './to-done.js';
export { toPending } from './to-pending.js';
export { toInProgress } from './to-in-progress.js';
// Updates
export { updateTask } from './update-task.js';
export { updateSingleTask } from './update-single-task.js';
export { updateTasksFromId } from './update-tasks-from-id.js';
// Workflows
export { tmMain } from './tm-main.js';
export { smartWorkflow } from './smart-workflow.js';
export { learn } from './learn.js';
export { commandPipeline } from './command-pipeline.js';
export { autoImplementTasks } from './auto-implement-tasks.js';
// Other
export { analyzeProject } from './analyze-project.js';
export { syncReadme } from './sync-readme.js';

View File

@@ -0,0 +1,120 @@
/**
* @fileoverview Learn Slash Command
* Learn about Task Master capabilities through interactive exploration.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The learn slash command - Learn
*
* Learn about Task Master capabilities through interactive exploration.
*/
export const learn = dynamicCommand(
'learn',
'Learn',
'[topic]',
`Learn about Task Master capabilities through interactive exploration.
Arguments: $ARGUMENTS
## Interactive Task Master Learning
Based on your input, I'll help you discover capabilities:
### 1. **What are you trying to do?**
If $ARGUMENTS contains:
- "start" / "begin" → Show project initialization workflows
- "manage" / "organize" → Show task management commands
- "automate" / "auto" → Show automation workflows
- "analyze" / "report" → Show analysis tools
- "fix" / "problem" → Show troubleshooting commands
- "fast" / "quick" → Show efficiency shortcuts
### 2. **Intelligent Suggestions**
Based on your project state:
**No tasks yet?**
\`\`\`
You'll want to start with:
1. /project:task-master:init <prd-file>
→ Creates tasks from requirements
2. /project:task-master:parse-prd <file>
→ Alternative task generation
Try: /project:task-master:init demo-prd.md
\`\`\`
**Have tasks?**
Let me analyze what you might need...
- Many pending tasks? → Learn sprint planning
- Complex tasks? → Learn task expansion
- Daily work? → Learn workflow automation
### 3. **Command Discovery**
**By Category:**
- 📋 Task Management: list, show, add, update, complete
- 🔄 Workflows: auto-implement, sprint-plan, daily-standup
- 🛠️ Utilities: check-health, complexity-report, sync-memory
- 🔍 Analysis: validate-deps, show dependencies
**By Scenario:**
- "I want to see what to work on" → \`/project:task-master:next\`
- "I need to break this down" → \`/project:task-master:expand <id>\`
- "Show me everything" → \`/project:task-master:status\`
- "Just do it for me" → \`/project:workflows:auto-implement\`
### 4. **Power User Patterns**
**Command Chaining:**
\`\`\`
/project:task-master:next
/project:task-master:start <id>
/project:workflows:auto-implement
\`\`\`
**Smart Filters:**
\`\`\`
/project:task-master:list pending high
/project:task-master:list blocked
/project:task-master:list 1-5 tree
\`\`\`
**Automation:**
\`\`\`
/project:workflows:pipeline init → expand-all → sprint-plan
\`\`\`
### 5. **Learning Path**
Based on your experience level:
**Beginner Path:**
1. init → Create project
2. status → Understand state
3. next → Find work
4. complete → Finish task
**Intermediate Path:**
1. expand → Break down complex tasks
2. sprint-plan → Organize work
3. complexity-report → Understand difficulty
4. validate-deps → Ensure consistency
**Advanced Path:**
1. pipeline → Chain operations
2. smart-flow → Context-aware automation
3. Custom commands → Extend the system
### 6. **Try This Now**
Based on what you asked about, try:
[Specific command suggestion based on $ARGUMENTS]
Want to learn more about a specific command?
Type: /project:help <command-name>`
);

View File

@@ -0,0 +1,56 @@
/**
* @fileoverview List Tasks By Status Slash Command
* List tasks filtered by a specific status.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The list-tasks-by-status slash command - List Tasks By Status
*
* List tasks filtered by a specific status.
*/
export const listTasksByStatus = dynamicCommand(
'list-tasks-by-status',
'List Tasks By Status',
'<status>',
`List tasks filtered by a specific status.
Arguments: $ARGUMENTS
Parse the status from arguments and list only tasks matching that status.
## Status Options
- \`pending\` - Not yet started
- \`in-progress\` - Currently being worked on
- \`done\` - Completed
- \`review\` - Awaiting review
- \`deferred\` - Postponed
- \`cancelled\` - Cancelled
## Execution
Based on $ARGUMENTS, run:
\`\`\`bash
task-master list --status=$ARGUMENTS
\`\`\`
## Enhanced Display
For the filtered results:
- Group by priority within the status
- Show time in current status
- Highlight tasks approaching deadlines
- Display blockers and dependencies
- Suggest next actions for each status group
## Intelligent Insights
Based on the status filter:
- **Pending**: Show recommended start order
- **In-Progress**: Display idle time warnings
- **Done**: Show newly unblocked tasks
- **Review**: Indicate review duration
- **Deferred**: Show reactivation criteria
- **Cancelled**: Display impact analysis`
);

View File

@@ -0,0 +1,45 @@
/**
* @fileoverview List Tasks With Subtasks Slash Command
* List all tasks including their subtasks in a hierarchical view.
*/
import { staticCommand } from '../../factories.js';
/**
* The list-tasks-with-subtasks slash command - List Tasks With Subtasks
*
* List all tasks including their subtasks in a hierarchical view.
*/
export const listTasksWithSubtasks = staticCommand({
name: 'list-tasks-with-subtasks',
description: 'List Tasks With Subtasks',
content: `List all tasks including their subtasks in a hierarchical view.
This command shows all tasks with their nested subtasks, providing a complete project overview.
## Execution
Run the Task Master list command with subtasks flag:
\`\`\`bash
task-master list --with-subtasks
\`\`\`
## Enhanced Display
I'll organize the output to show:
- Parent tasks with clear indicators
- Nested subtasks with proper indentation
- Status badges for quick scanning
- Dependencies and blockers highlighted
- Progress indicators for tasks with subtasks
## Smart Filtering
Based on the task hierarchy:
- Show completion percentage for parent tasks
- Highlight blocked subtask chains
- Group by functional areas
- Indicate critical path items
This gives you a complete tree view of your project structure.`
});

View File

@@ -0,0 +1,60 @@
/**
* @fileoverview List Tasks Slash Command
* List tasks with intelligent argument parsing.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The list-tasks slash command - List Tasks
*
* List tasks with intelligent argument parsing.
*/
export const listTasks = dynamicCommand(
'list-tasks',
'List Tasks',
'[filters]',
`List tasks with intelligent argument parsing.
Parse arguments to determine filters and display options:
- Status: pending, in-progress, done, review, deferred, cancelled
- Priority: high, medium, low (or priority:high)
- Special: subtasks, tree, dependencies, blocked
- IDs: Direct numbers (e.g., "1,3,5" or "1-5")
- Complex: "pending high" = pending AND high priority
Arguments: $ARGUMENTS
Let me parse your request intelligently:
1. **Detect Filter Intent**
- If arguments contain status keywords → filter by status
- If arguments contain priority → filter by priority
- If arguments contain "subtasks" → include subtasks
- If arguments contain "tree" → hierarchical view
- If arguments contain numbers → show specific tasks
- If arguments contain "blocked" → show blocked tasks only
2. **Smart Combinations**
Examples of what I understand:
- "pending high" → pending tasks with high priority
- "done today" → tasks completed today
- "blocked" → tasks with unmet dependencies
- "1-5" → tasks 1 through 5
- "subtasks tree" → hierarchical view with subtasks
3. **Execute Appropriate Query**
Based on parsed intent, run the most specific task-master command
4. **Enhanced Display**
- Group by relevant criteria
- Show most important information first
- Use visual indicators for quick scanning
- Include relevant metrics
5. **Intelligent Suggestions**
Based on what you're viewing, suggest next actions:
- Many pending? → Suggest priority order
- Many blocked? → Show dependency resolution
- Looking at specific tasks? → Show related tasks`
);

View File

@@ -0,0 +1,83 @@
/**
* @fileoverview Next Task Slash Command
* Intelligently determine and prepare the next action based on comprehensive context.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The next-task slash command - Next Task
*
* Intelligently determine and prepare the next action based on comprehensive context.
*/
export const nextTask = dynamicCommand(
'next-task',
'Next Task',
'[preference]',
`Intelligently determine and prepare the next action based on comprehensive context.
This enhanced version of 'next' considers:
- Current task states
- Recent activity
- Time constraints
- Dependencies
- Your working patterns
Arguments: $ARGUMENTS
## Intelligent Next Action
### 1. **Context Gathering**
Let me analyze the current situation:
- Active tasks (in-progress)
- Recently completed tasks
- Blocked tasks
- Time since last activity
- Arguments provided: $ARGUMENTS
### 2. **Smart Decision Tree**
**If you have an in-progress task:**
- Has it been idle > 2 hours? → Suggest resuming or switching
- Near completion? → Show remaining steps
- Blocked? → Find alternative task
**If no in-progress tasks:**
- Unblocked high-priority tasks? → Start highest
- Complex tasks need breakdown? → Suggest expansion
- All tasks blocked? → Show dependency resolution
**Special arguments handling:**
- "quick" → Find task < 2 hours
- "easy" → Find low complexity task
- "important" → Find high priority regardless of complexity
- "continue" → Resume last worked task
### 3. **Preparation Workflow**
Based on selected task:
1. Show full context and history
2. Set up development environment
3. Run relevant tests
4. Open related files
5. Show similar completed tasks
6. Estimate completion time
### 4. **Alternative Suggestions**
Always provide options:
- Primary recommendation
- Quick alternative (< 1 hour)
- Strategic option (unblocks most tasks)
- Learning option (new technology/skill)
### 5. **Workflow Integration**
Seamlessly connect to:
- \`/project:task-master:start [selected]\`
- \`/project:workflows:auto-implement\`
- \`/project:task-master:expand\` (if complex)
- \`/project:utils:complexity-report\` (if unsure)
The goal: Zero friction from decision to implementation.`
);

View File

@@ -0,0 +1,81 @@
/**
* @fileoverview Project Status Slash Command
* Enhanced status command with comprehensive project insights.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The project-status slash command - Project Status
*
* Enhanced status command with comprehensive project insights.
*/
export const projectStatus = dynamicCommand(
'project-status',
'Project Status',
'[focus-area]',
`Enhanced status command with comprehensive project insights.
Arguments: $ARGUMENTS
## Intelligent Status Overview
### 1. **Executive Summary**
Quick dashboard view:
- 🏃 Active work (in-progress tasks)
- 📊 Progress metrics (% complete, velocity)
- 🚧 Blockers and risks
- ⏱️ Time analysis (estimated vs actual)
- 🎯 Sprint/milestone progress
### 2. **Contextual Analysis**
Based on $ARGUMENTS, focus on:
- "sprint" → Current sprint progress and burndown
- "blocked" → Dependency chains and resolution paths
- "team" → Task distribution and workload
- "timeline" → Schedule adherence and projections
- "risk" → High complexity or overdue items
### 3. **Smart Insights**
**Workflow Health:**
- Idle tasks (in-progress > 24h without updates)
- Bottlenecks (multiple tasks waiting on same dependency)
- Quick wins (low complexity, high impact)
**Predictive Analytics:**
- Completion projections based on velocity
- Risk of missing deadlines
- Recommended task order for optimal flow
### 4. **Visual Intelligence**
Dynamic visualization based on data:
\`\`\`
Sprint Progress: ████████░░ 80% (16/20 tasks)
Velocity Trend: ↗️ +15% this week
Blocked Tasks: 🔴 3 critical path items
Priority Distribution:
High: ████████ 8 tasks (2 blocked)
Medium: ████░░░░ 4 tasks
Low: ██░░░░░░ 2 tasks
\`\`\`
### 5. **Actionable Recommendations**
Based on analysis:
1. **Immediate actions** (unblock critical path)
2. **Today's focus** (optimal task sequence)
3. **Process improvements** (recurring patterns)
4. **Resource needs** (skills, time, dependencies)
### 6. **Historical Context**
Compare to previous periods:
- Velocity changes
- Pattern recognition
- Improvement areas
- Success patterns to repeat`
);

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview Show Task Slash Command
* Show detailed task information with rich context and insights.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The show-task slash command - Show Task
*
* Show detailed task information with rich context and insights.
*/
export const showTask = dynamicCommand(
'show-task',
'Show Task',
'<task-id>',
`Show detailed task information with rich context and insights.
Arguments: $ARGUMENTS
## Enhanced Task Display
Parse arguments to determine what to show and how.
### 1. **Smart Task Selection**
Based on $ARGUMENTS:
- Number → Show specific task with full context
- "current" → Show active in-progress task(s)
- "next" → Show recommended next task
- "blocked" → Show all blocked tasks with reasons
- "critical" → Show critical path tasks
- Multiple IDs → Comparative view
### 2. **Contextual Information**
For each task, intelligently include:
**Core Details**
- Full task information (id, title, description, details)
- Current status with history
- Test strategy and acceptance criteria
- Priority and complexity analysis
**Relationships**
- Dependencies (what it needs)
- Dependents (what needs it)
- Parent/subtask hierarchy
- Related tasks (similar work)
**Time Intelligence**
- Created/updated timestamps
- Time in current status
- Estimated vs actual time
- Historical completion patterns
### 3. **Visual Enhancements**
\`\`\`
📋 Task #45: Implement User Authentication
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Status: 🟡 in-progress (2 hours)
Priority: 🔴 High | Complexity: 73/100
Dependencies: ✅ #41, ✅ #42, ⏳ #43 (blocked)
Blocks: #46, #47, #52
Progress: ████████░░ 80% complete
Recent Activity:
- 2h ago: Status changed to in-progress
- 4h ago: Dependency #42 completed
- Yesterday: Task expanded with 3 subtasks
\`\`\`
### 4. **Intelligent Insights**
Based on task analysis:
- **Risk Assessment**: Complexity vs time remaining
- **Bottleneck Analysis**: Is this blocking critical work?
- **Recommendation**: Suggested approach or concerns
- **Similar Tasks**: How others completed similar work
### 5. **Action Suggestions**
Context-aware next steps:
- If blocked → Show how to unblock
- If complex → Suggest expansion
- If in-progress → Show completion checklist
- If done → Show dependent tasks ready to start
### 6. **Multi-Task View**
When showing multiple tasks:
- Common dependencies
- Optimal completion order
- Parallel work opportunities
- Combined complexity analysis`
);

View File

@@ -0,0 +1,72 @@
/**
* @fileoverview Smart Workflow Slash Command
* Execute an intelligent workflow based on current project state and recent commands.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The smart-workflow slash command - Smart Workflow
*
* Execute an intelligent workflow based on current project state and recent commands.
*/
export const smartWorkflow = dynamicCommand(
'smart-workflow',
'Smart Workflow',
'[context]',
`Execute an intelligent workflow based on current project state and recent commands.
This command analyzes:
1. Recent commands you've run
2. Current project state
3. Time of day / day of week
4. Your working patterns
Arguments: $ARGUMENTS
## Intelligent Workflow Selection
Based on context, I'll determine the best workflow:
### Context Analysis
- Previous command executed
- Current task states
- Unfinished work from last session
- Your typical patterns
### Smart Execution
If last command was:
- \`status\` → Likely starting work → Run daily standup
- \`complete\` → Task finished → Find next task
- \`list pending\` → Planning → Suggest sprint planning
- \`expand\` → Breaking down work → Show complexity analysis
- \`init\` → New project → Show onboarding workflow
If no recent commands:
- Morning? → Daily standup workflow
- Many pending tasks? → Sprint planning
- Tasks blocked? → Dependency resolution
- Friday? → Weekly review
### Workflow Composition
I'll chain appropriate commands:
1. Analyze current state
2. Execute primary workflow
3. Suggest follow-up actions
4. Prepare environment for coding
### Learning Mode
This command learns from your patterns:
- Track command sequences
- Note time preferences
- Remember common workflows
- Adapt to your style
Example flows detected:
- Morning: standup → next → start
- After lunch: status → continue task
- End of day: complete → commit → status`
);

View File

@@ -0,0 +1,134 @@
/**
* @fileoverview Sync README Slash Command
* Export tasks to README.md with professional formatting.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The sync-readme slash command - Sync README
*
* Export tasks to README.md with professional formatting.
*/
export const syncReadme = dynamicCommand(
'sync-readme',
'Sync README',
'[options]',
`Export tasks to README.md with professional formatting.
Arguments: $ARGUMENTS
Generate a well-formatted README with current task information.
## README Synchronization
Creates or updates README.md with beautifully formatted task information.
## Argument Parsing
Optional filters:
- "pending" → Only pending tasks
- "with-subtasks" → Include subtask details
- "by-priority" → Group by priority
- "sprint" → Current sprint only
## Execution
\`\`\`bash
task-master sync-readme [--with-subtasks] [--status=<status>]
\`\`\`
## README Generation
### 1. **Project Header**
\`\`\`markdown
# Project Name
## 📋 Task Progress
Last Updated: 2024-01-15 10:30 AM
### Summary
- Total Tasks: 45
- Completed: 15 (33%)
- In Progress: 5 (11%)
- Pending: 25 (56%)
\`\`\`
### 2. **Task Sections**
Organized by status or priority:
- Progress indicators
- Task descriptions
- Dependencies noted
- Time estimates
### 3. **Visual Elements**
- Progress bars
- Status badges
- Priority indicators
- Completion checkmarks
## Smart Features
1. **Intelligent Grouping**
- By feature area
- By sprint/milestone
- By assigned developer
- By priority
2. **Progress Tracking**
- Overall completion
- Sprint velocity
- Burndown indication
- Time tracking
3. **Formatting Options**
- GitHub-flavored markdown
- Task checkboxes
- Collapsible sections
- Table format available
## Example Output
\`\`\`markdown
## 🚀 Current Sprint
### In Progress
- [ ] 🔄 #5 **Implement user authentication** (60% complete)
- Dependencies: API design (#3 ✅)
- Subtasks: 4 (2 completed)
- Est: 8h / Spent: 5h
### Pending (High Priority)
- [ ] ⚡ #8 **Create dashboard UI**
- Blocked by: #5
- Complexity: High
- Est: 12h
\`\`\`
## Customization
Based on arguments:
- Include/exclude sections
- Detail level control
- Custom grouping
- Filter by criteria
## Post-Sync
After generation:
1. Show diff preview
2. Backup existing README
3. Write new content
4. Commit reminder
5. Update timestamp
## Integration
Works well with:
- Git workflows
- CI/CD pipelines
- Project documentation
- Team updates
- Client reports`
);

View File

@@ -0,0 +1,162 @@
/**
* @fileoverview TM Main Slash Command
* Task Master Command Reference - comprehensive command structure.
*/
import { staticCommand } from '../../factories.js';
/**
* The tm-main slash command - Task Master Main
*
* Task Master Command Reference - comprehensive command structure.
*/
export const tmMain = staticCommand({
name: 'tm-main',
description: 'Task Master Main',
content: `# Task Master Command Reference
Comprehensive command structure for Task Master integration with Claude Code.
## Command Organization
Commands are organized hierarchically to match Task Master's CLI structure while providing enhanced Claude Code integration.
## Project Setup & Configuration
### \`/taskmaster:init\`
- \`init-project\` - Initialize new project (handles PRD files intelligently)
- \`init-project-quick\` - Quick setup with auto-confirmation (-y flag)
### \`/taskmaster:models\`
- \`view-models\` - View current AI model configuration
- \`setup-models\` - Interactive model configuration
- \`set-main\` - Set primary generation model
- \`set-research\` - Set research model
- \`set-fallback\` - Set fallback model
## Task Generation
### \`/taskmaster:parse-prd\`
- \`parse-prd\` - Generate tasks from PRD document
- \`parse-prd-with-research\` - Enhanced parsing with research mode
### \`/taskmaster:generate\`
- \`generate-tasks\` - Create individual task files from tasks.json
## Task Management
### \`/taskmaster:list\`
- \`list-tasks\` - Smart listing with natural language filters
- \`list-tasks-with-subtasks\` - Include subtasks in hierarchical view
- \`list-tasks-by-status\` - Filter by specific status
### \`/taskmaster:set-status\`
- \`to-pending\` - Reset task to pending
- \`to-in-progress\` - Start working on task
- \`to-done\` - Mark task complete
- \`to-review\` - Submit for review
- \`to-deferred\` - Defer task
- \`to-cancelled\` - Cancel task
### \`/taskmaster:sync-readme\`
- \`sync-readme\` - Export tasks to README.md with formatting
### \`/taskmaster:update\`
- \`update-task\` - Update tasks with natural language
- \`update-tasks-from-id\` - Update multiple tasks from a starting point
- \`update-single-task\` - Update specific task
### \`/taskmaster:add-task\`
- \`add-task\` - Add new task with AI assistance
### \`/taskmaster:remove-task\`
- \`remove-task\` - Remove task with confirmation
## Subtask Management
### \`/taskmaster:add-subtask\`
- \`add-subtask\` - Add new subtask to parent
- \`convert-task-to-subtask\` - Convert existing task to subtask
### \`/taskmaster:remove-subtask\`
- \`remove-subtask\` - Remove subtask (with optional conversion)
### \`/taskmaster:clear-subtasks\`
- \`clear-subtasks\` - Clear subtasks from specific task
- \`clear-all-subtasks\` - Clear all subtasks globally
## Task Analysis & Breakdown
### \`/taskmaster:analyze-complexity\`
- \`analyze-complexity\` - Analyze and generate expansion recommendations
### \`/taskmaster:complexity-report\`
- \`complexity-report\` - Display complexity analysis report
### \`/taskmaster:expand\`
- \`expand-task\` - Break down specific task
- \`expand-all-tasks\` - Expand all eligible tasks
- \`with-research\` - Enhanced expansion
## Task Navigation
### \`/taskmaster:next\`
- \`next-task\` - Intelligent next task recommendation
### \`/taskmaster:show\`
- \`show-task\` - Display detailed task information
### \`/taskmaster:status\`
- \`project-status\` - Comprehensive project dashboard
## Dependency Management
### \`/taskmaster:add-dependency\`
- \`add-dependency\` - Add task dependency
### \`/taskmaster:remove-dependency\`
- \`remove-dependency\` - Remove task dependency
### \`/taskmaster:validate-dependencies\`
- \`validate-dependencies\` - Check for dependency issues
### \`/taskmaster:fix-dependencies\`
- \`fix-dependencies\` - Automatically fix dependency problems
## Workflows & Automation
### \`/taskmaster:workflows\`
- \`smart-workflow\` - Context-aware intelligent workflow execution
- \`command-pipeline\` - Chain multiple commands together
- \`auto-implement-tasks\` - Advanced auto-implementation with code generation
## Utilities
### \`/taskmaster:utils\`
- \`analyze-project\` - Deep project analysis and insights
### \`/taskmaster:setup\`
- \`install-taskmaster\` - Comprehensive installation guide
- \`quick-install-taskmaster\` - One-line global installation
## Usage Patterns
### Natural Language
Most commands accept natural language arguments:
\`\`\`
/taskmaster:add-task create user authentication system
/taskmaster:update mark all API tasks as high priority
/taskmaster:list show blocked tasks
\`\`\`
### ID-Based Commands
Commands requiring IDs intelligently parse from $ARGUMENTS:
\`\`\`
/taskmaster:show 45
/taskmaster:expand 23
/taskmaster:set-status/to-done 67
\`\`\`
### Smart Defaults
Commands provide intelligent defaults and suggestions based on context.`
});

View File

@@ -0,0 +1,61 @@
/**
* @fileoverview To Done Slash Command
* Mark a task as completed.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The to-done slash command - To Done
*
* Mark a task as completed.
*/
export const toDone = dynamicCommand(
'to-done',
'To Done',
'<task-id>',
`Mark a task as completed.
Arguments: $ARGUMENTS (task ID)
## Completing a Task
This command validates task completion and updates project state intelligently.
## Pre-Completion Checks
1. Verify test strategy was followed
2. Check if all subtasks are complete
3. Validate acceptance criteria met
4. Ensure code is committed
## Execution
\`\`\`bash
task-master set-status --id=$ARGUMENTS --status=done
\`\`\`
## Post-Completion Actions
1. **Update Dependencies**
- Identify newly unblocked tasks
- Update sprint progress
- Recalculate project timeline
2. **Documentation**
- Generate completion summary
- Update CLAUDE.md with learnings
- Log implementation approach
3. **Next Steps**
- Show newly available tasks
- Suggest logical next task
- Update velocity metrics
## Celebration & Learning
- Show impact of completion
- Display unblocked work
- Recognize achievement
- Capture lessons learned`
);

View File

@@ -0,0 +1,53 @@
/**
* @fileoverview To In Progress Slash Command
* Start working on a task by setting its status to in-progress.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The to-in-progress slash command - To In Progress
*
* Start working on a task by setting its status to in-progress.
*/
export const toInProgress = dynamicCommand(
'to-in-progress',
'To In Progress',
'<task-id>',
`Start working on a task by setting its status to in-progress.
Arguments: $ARGUMENTS (task ID)
## Starting Work on Task
This command does more than just change status - it prepares your environment for productive work.
## Pre-Start Checks
1. Verify dependencies are met
2. Check if another task is already in-progress
3. Ensure task details are complete
4. Validate test strategy exists
## Execution
\`\`\`bash
task-master set-status --id=$ARGUMENTS --status=in-progress
\`\`\`
## Environment Setup
After setting to in-progress:
1. Create/checkout appropriate git branch
2. Open relevant documentation
3. Set up test watchers if applicable
4. Display task details and acceptance criteria
5. Show similar completed tasks for reference
## Smart Suggestions
- Estimated completion time based on complexity
- Related files from similar tasks
- Potential blockers to watch for
- Recommended first steps`
);

View File

@@ -0,0 +1,49 @@
/**
* @fileoverview To Pending Slash Command
* Set a task's status to pending.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The to-pending slash command - To Pending
*
* Set a task's status to pending.
*/
export const toPending = dynamicCommand(
'to-pending',
'To Pending',
'<task-id>',
`Set a task's status to pending.
Arguments: $ARGUMENTS (task ID)
## Setting Task to Pending
This moves a task back to the pending state, useful for:
- Resetting erroneously started tasks
- Deferring work that was prematurely begun
- Reorganizing sprint priorities
## Execution
\`\`\`bash
task-master set-status --id=$ARGUMENTS --status=pending
\`\`\`
## Validation
Before setting to pending:
- Warn if task is currently in-progress
- Check if this will block other tasks
- Suggest documenting why it's being reset
- Preserve any work already done
## Smart Actions
After setting to pending:
- Update sprint planning if needed
- Notify about freed resources
- Suggest priority reassessment
- Log the status change with context`
);

View File

@@ -0,0 +1,136 @@
/**
* @fileoverview Update Single Task Slash Command
* Update a single specific task with new information.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The update-single-task slash command - Update Single Task
*
* Update a single specific task with new information.
*/
export const updateSingleTask = dynamicCommand(
'update-single-task',
'Update Single Task',
'<task-id> <changes>',
`Update a single specific task with new information.
Arguments: $ARGUMENTS
Parse task ID and update details.
## Single Task Update
Precisely update one task with AI assistance to maintain consistency.
## Argument Parsing
Natural language updates:
- "5: add caching requirement"
- "update 5 to include error handling"
- "task 5 needs rate limiting"
- "5 change priority to high"
## Execution
\`\`\`bash
task-master update-task --id=<id> --prompt="<context>"
\`\`\`
## Update Types
### 1. **Content Updates**
- Enhance description
- Add requirements
- Clarify details
- Update acceptance criteria
### 2. **Metadata Updates**
- Change priority
- Adjust time estimates
- Update complexity
- Modify dependencies
### 3. **Strategic Updates**
- Revise approach
- Change test strategy
- Update implementation notes
- Adjust subtask needs
## AI-Powered Updates
The AI:
1. **Understands Context**
- Reads current task state
- Identifies update intent
- Maintains consistency
- Preserves important info
2. **Applies Changes**
- Updates relevant fields
- Keeps style consistent
- Adds without removing
- Enhances clarity
3. **Validates Results**
- Checks coherence
- Verifies completeness
- Maintains relationships
- Suggests related updates
## Example Updates
\`\`\`
/taskmaster:update/single 5: add rate limiting
→ Updating Task #5: "Implement API endpoints"
Current: Basic CRUD endpoints
Adding: Rate limiting requirements
Updated sections:
✓ Description: Added rate limiting mention
✓ Details: Added specific limits (100/min)
✓ Test Strategy: Added rate limit tests
✓ Complexity: Increased from 5 to 6
✓ Time Estimate: Increased by 2 hours
Suggestion: Also update task #6 (API Gateway) for consistency?
\`\`\`
## Smart Features
1. **Incremental Updates**
- Adds without overwriting
- Preserves work history
- Tracks what changed
- Shows diff view
2. **Consistency Checks**
- Related task alignment
- Subtask compatibility
- Dependency validity
- Timeline impact
3. **Update History**
- Timestamp changes
- Track who/what updated
- Reason for update
- Previous versions
## Field-Specific Updates
Quick syntax for specific fields:
- "5 priority:high" → Update priority only
- "5 add-time:4h" → Add to time estimate
- "5 status:review" → Change status
- "5 depends:3,4" → Add dependencies
## Post-Update
- Show updated task
- Highlight changes
- Check related tasks
- Update suggestions
- Timeline adjustments`
);

View File

@@ -0,0 +1,89 @@
/**
* @fileoverview Update Task Slash Command
* Update tasks with intelligent field detection and bulk operations.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The update-task slash command - Update Task
*
* Update tasks with intelligent field detection and bulk operations.
*/
export const updateTask = dynamicCommand(
'update-task',
'Update Task',
'<update-spec>',
`Update tasks with intelligent field detection and bulk operations.
Arguments: $ARGUMENTS
## Intelligent Task Updates
Parse arguments to determine update intent and execute smartly.
### 1. **Natural Language Processing**
Understand update requests like:
- "mark 23 as done" → Update status to done
- "increase priority of 45" → Set priority to high
- "add dependency on 12 to task 34" → Add dependency
- "tasks 20-25 need review" → Bulk status update
- "all API tasks high priority" → Pattern-based update
### 2. **Smart Field Detection**
Automatically detect what to update:
- Status keywords: done, complete, start, pause, review
- Priority changes: urgent, high, low, deprioritize
- Dependency updates: depends on, blocks, after
- Assignment: assign to, owner, responsible
- Time: estimate, spent, deadline
### 3. **Bulk Operations**
Support for multiple task updates:
\`\`\`
Examples:
- "complete tasks 12, 15, 18"
- "all pending auth tasks to in-progress"
- "increase priority for tasks blocking 45"
- "defer all documentation tasks"
\`\`\`
### 4. **Contextual Validation**
Before updating, check:
- Status transitions are valid
- Dependencies don't create cycles
- Priority changes make sense
- Bulk updates won't break project flow
Show preview:
\`\`\`
Update Preview:
─────────────────
Tasks to update: #23, #24, #25
Change: status → in-progress
Impact: Will unblock tasks #30, #31
Warning: Task #24 has unmet dependencies
\`\`\`
### 5. **Smart Suggestions**
Based on update:
- Completing task? → Show newly unblocked tasks
- Changing priority? → Show impact on sprint
- Adding dependency? → Check for conflicts
- Bulk update? → Show summary of changes
### 6. **Workflow Integration**
After updates:
- Auto-update dependent task states
- Trigger status recalculation
- Update sprint/milestone progress
- Log changes with context
Result: Flexible, intelligent task updates with safety checks.`
);

View File

@@ -0,0 +1,125 @@
/**
* @fileoverview Update Tasks From ID Slash Command
* Update multiple tasks starting from a specific ID.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The update-tasks-from-id slash command - Update Tasks From ID
*
* Update multiple tasks starting from a specific ID.
*/
export const updateTasksFromId = dynamicCommand(
'update-tasks-from-id',
'Update Tasks From ID',
'<from-id> <changes>',
`Update multiple tasks starting from a specific ID.
Arguments: $ARGUMENTS
Parse starting task ID and update context.
## Bulk Task Updates
Update multiple related tasks based on new requirements or context changes.
## Argument Parsing
- "from 5: add security requirements"
- "5 onwards: update API endpoints"
- "starting at 5: change to use new framework"
## Execution
\`\`\`bash
task-master update --from=<id> --prompt="<context>"
\`\`\`
## Update Process
### 1. **Task Selection**
Starting from specified ID:
- Include the task itself
- Include all dependent tasks
- Include related subtasks
- Smart boundary detection
### 2. **Context Application**
AI analyzes the update context and:
- Identifies what needs changing
- Maintains consistency
- Preserves completed work
- Updates related information
### 3. **Intelligent Updates**
- Modify descriptions appropriately
- Update test strategies
- Adjust time estimates
- Revise dependencies if needed
## Smart Features
1. **Scope Detection**
- Find natural task groupings
- Identify related features
- Stop at logical boundaries
- Avoid over-updating
2. **Consistency Maintenance**
- Keep naming conventions
- Preserve relationships
- Update cross-references
- Maintain task flow
3. **Change Preview**
\`\`\`
Bulk Update Preview
━━━━━━━━━━━━━━━━━━
Starting from: Task #5
Tasks to update: 8 tasks + 12 subtasks
Context: "add security requirements"
Changes will include:
- Add security sections to descriptions
- Update test strategies for security
- Add security-related subtasks where needed
- Adjust time estimates (+20% average)
Continue? (y/n)
\`\`\`
## Example Updates
\`\`\`
/taskmaster:update-tasks-from-id 5: change database to PostgreSQL
→ Analyzing impact starting from task #5
→ Found 6 related tasks to update
→ Updates will maintain consistency
→ Preview changes? (y/n)
Applied updates:
✓ Task #5: Updated connection logic references
✓ Task #6: Changed migration approach
✓ Task #7: Updated query syntax notes
✓ Task #8: Revised testing strategy
✓ Task #9: Updated deployment steps
✓ Task #12: Changed backup procedures
\`\`\`
## Safety Features
- Preview all changes
- Selective confirmation
- Rollback capability
- Change logging
- Validation checks
## Post-Update
- Summary of changes
- Consistency verification
- Suggest review tasks
- Update timeline if needed`
);

View File

@@ -0,0 +1,171 @@
/**
* @fileoverview Slash Commands Index
* Exports all TaskMaster slash commands organized by operating mode.
*/
import type { SlashCommand } from '../types.js';
// Solo commands (local file-based storage)
import {
parsePrd,
parsePrdWithResearch,
analyzeComplexity,
complexityReport,
expandTask,
expandAllTasks,
addTask,
addSubtask,
removeTask,
removeSubtask,
removeSubtasks,
removeAllSubtasks,
convertTaskToSubtask,
addDependency,
removeDependency,
fixDependencies,
validateDependencies,
setupModels,
viewModels,
installTaskmaster,
quickInstallTaskmaster,
toReview,
toDeferred,
toCancelled,
initProject,
initProjectQuick
} from './solo/index.js';
// Team commands (API-based storage via Hamster)
import { goham } from './team/index.js';
// Common commands (work in both modes)
import {
showTask,
listTasks,
listTasksWithSubtasks,
listTasksByStatus,
projectStatus,
nextTask,
help,
toDone,
toPending,
toInProgress,
updateTask,
updateSingleTask,
updateTasksFromId,
tmMain,
smartWorkflow,
learn,
commandPipeline,
autoImplementTasks,
analyzeProject,
syncReadme
} from './common/index.js';
/**
* All TaskMaster slash commands
* Add new commands here to have them automatically distributed to all profiles.
*/
export const allCommands: SlashCommand[] = [
// Solo commands
parsePrd,
parsePrdWithResearch,
analyzeComplexity,
complexityReport,
expandTask,
expandAllTasks,
addTask,
addSubtask,
removeTask,
removeSubtask,
removeSubtasks,
removeAllSubtasks,
convertTaskToSubtask,
addDependency,
removeDependency,
fixDependencies,
validateDependencies,
setupModels,
viewModels,
installTaskmaster,
quickInstallTaskmaster,
toReview,
toDeferred,
toCancelled,
initProject,
initProjectQuick,
// Team commands
goham,
// Common commands
showTask,
listTasks,
listTasksWithSubtasks,
listTasksByStatus,
projectStatus,
nextTask,
help,
toDone,
toPending,
toInProgress,
updateTask,
updateSingleTask,
updateTasksFromId,
tmMain,
smartWorkflow,
learn,
commandPipeline,
autoImplementTasks,
analyzeProject,
syncReadme
];
/**
* Filter commands by operating mode
*
* Both modes include common commands:
* - Solo mode: solo + common commands
* - Team mode: team + common commands
*
* @param commands - Array of slash commands to filter
* @param mode - Operating mode ('solo' or 'team')
* @returns Filtered array of commands for the specified mode
*/
export function filterCommandsByMode(
commands: SlashCommand[],
mode: 'solo' | 'team'
): SlashCommand[] {
if (mode === 'team') {
// Team mode: team + common commands
return commands.filter(
(cmd) =>
cmd.metadata.mode === 'team' ||
cmd.metadata.mode === 'common' ||
!cmd.metadata.mode // backward compat: no mode = common
);
}
// Solo mode: solo + common commands
return commands.filter(
(cmd) =>
cmd.metadata.mode === 'solo' ||
cmd.metadata.mode === 'common' ||
!cmd.metadata.mode // backward compat: no mode = common
);
}
/** Commands for solo mode (solo + common) */
export const soloCommands = filterCommandsByMode(allCommands, 'solo');
/** Commands for team mode (team + common) */
export const teamCommands = filterCommandsByMode(allCommands, 'team');
/** Commands that work in both modes */
export const commonCommands = allCommands.filter(
(cmd) => cmd.metadata.mode === 'common' || !cmd.metadata.mode
);
// Re-export from subdirectories for direct access
export * from './solo/index.js';
export * from './team/index.js';
export * from './common/index.js';

View File

@@ -0,0 +1,312 @@
/**
* @fileoverview Unit tests for mode-based command filtering
*
* Tests the filterCommandsByMode function and command mode categorization:
* - Solo mode: Returns solo + common commands
* - Team mode: Returns team + common commands
*/
import { describe, it, expect } from 'vitest';
import {
filterCommandsByMode,
allCommands,
soloCommands,
teamCommands,
commonCommands
} from './index.js';
describe('Mode-based Command Filtering', () => {
describe('filterCommandsByMode', () => {
describe('solo mode filtering', () => {
it('returns solo and common commands for solo mode', () => {
// Act
const filtered = filterCommandsByMode(allCommands, 'solo');
// Assert
for (const cmd of filtered) {
const mode = cmd.metadata.mode;
expect(
mode === 'solo' || mode === 'common' || mode === undefined
).toBe(true);
}
});
it('excludes team-only commands from solo mode', () => {
// Act
const filtered = filterCommandsByMode(allCommands, 'solo');
// Assert
const teamOnlyCommands = filtered.filter(
(cmd) => cmd.metadata.mode === 'team'
);
expect(teamOnlyCommands).toHaveLength(0);
});
it('includes commands without explicit mode (backward compat)', () => {
// Act
const filtered = filterCommandsByMode(allCommands, 'solo');
// Assert - commands without mode should be included
// This is a backward compat check - commands with undefined mode are treated as common
expect(filtered.length).toBeGreaterThan(0);
});
});
describe('team mode filtering', () => {
it('returns team and common commands for team mode', () => {
// Act
const filtered = filterCommandsByMode(allCommands, 'team');
// Assert - team mode includes team + common commands
for (const cmd of filtered) {
const mode = cmd.metadata.mode;
expect(
mode === 'team' || mode === 'common' || mode === undefined
).toBe(true);
}
});
it('excludes solo commands from team mode', () => {
// Act
const filtered = filterCommandsByMode(allCommands, 'team');
// Assert
const soloInTeam = filtered.filter(
(cmd) => cmd.metadata.mode === 'solo'
);
expect(soloInTeam).toHaveLength(0);
});
it('includes common commands in team mode', () => {
// Act
const filtered = filterCommandsByMode(allCommands, 'team');
// Assert - team mode includes common commands
const commonInTeam = filtered.filter(
(cmd) =>
cmd.metadata.mode === 'common' || cmd.metadata.mode === undefined
);
expect(commonInTeam.length).toBeGreaterThan(0);
});
});
});
describe('Pre-filtered exports', () => {
describe('soloCommands export', () => {
it('matches filterCommandsByMode(allCommands, "solo")', () => {
// Act
const expectedSolo = filterCommandsByMode(allCommands, 'solo');
// Assert
expect(soloCommands).toHaveLength(expectedSolo.length);
const soloNames = soloCommands.map((c) => c.metadata.name);
const expectedNames = expectedSolo.map((c) => c.metadata.name);
expect(soloNames.sort()).toEqual(expectedNames.sort());
});
it('contains known solo commands', () => {
// Assert - verify some known solo commands are present
const names = soloCommands.map((c) => c.metadata.name);
expect(names).toContain('parse-prd');
expect(names).toContain('add-task');
expect(names).toContain('expand-task');
});
it('contains common commands', () => {
// Assert - verify common commands are included in solo
const names = soloCommands.map((c) => c.metadata.name);
expect(names).toContain('show-task');
expect(names).toContain('list-tasks');
expect(names).toContain('to-done');
});
it('does not contain team commands', () => {
// Assert
const names = soloCommands.map((c) => c.metadata.name);
expect(names).not.toContain('goham');
});
});
describe('teamCommands export', () => {
it('matches filterCommandsByMode(allCommands, "team")', () => {
// Act
const expectedTeam = filterCommandsByMode(allCommands, 'team');
// Assert
expect(teamCommands).toHaveLength(expectedTeam.length);
const teamNames = teamCommands.map((c) => c.metadata.name);
const expectedNames = expectedTeam.map((c) => c.metadata.name);
expect(teamNames.sort()).toEqual(expectedNames.sort());
});
it('contains goham command', () => {
// Assert
const names = teamCommands.map((c) => c.metadata.name);
expect(names).toContain('goham');
});
it('does not contain solo commands', () => {
// Assert
const names = teamCommands.map((c) => c.metadata.name);
expect(names).not.toContain('parse-prd');
expect(names).not.toContain('add-task');
});
it('contains common commands', () => {
// Assert - team mode includes common commands
const names = teamCommands.map((c) => c.metadata.name);
expect(names).toContain('show-task');
expect(names).toContain('list-tasks');
expect(names).toContain('help');
});
});
describe('commonCommands export', () => {
it('contains only commands with mode=common or undefined', () => {
// Assert
for (const cmd of commonCommands) {
const mode = cmd.metadata.mode;
expect(mode === 'common' || mode === undefined).toBe(true);
}
});
it('contains known common commands', () => {
// Assert
const names = commonCommands.map((c) => c.metadata.name);
expect(names).toContain('show-task');
expect(names).toContain('list-tasks');
expect(names).toContain('next-task');
expect(names).toContain('help');
expect(names).toContain('to-done');
});
});
});
describe('Command mode categorization', () => {
it('all commands have valid mode property', () => {
// Assert
for (const cmd of allCommands) {
const mode = cmd.metadata.mode;
// Mode should be 'solo', 'team', 'common', or undefined
expect(
mode === 'solo' ||
mode === 'team' ||
mode === 'common' ||
mode === undefined
).toBe(true);
}
});
it('goham is the only team command', () => {
// Act
const teamOnly = allCommands.filter(
(cmd) => cmd.metadata.mode === 'team'
);
// Assert
expect(teamOnly).toHaveLength(1);
expect(teamOnly[0].metadata.name).toBe('goham');
});
it('solo commands are tagged correctly', () => {
// Known solo commands
const knownSolo = [
'parse-prd',
'parse-prd-with-research',
'analyze-complexity',
'complexity-report',
'expand-task',
'expand-all-tasks',
'add-task',
'add-subtask',
'remove-task',
'remove-subtask',
'remove-subtasks',
'remove-all-subtasks',
'convert-task-to-subtask',
'add-dependency',
'remove-dependency',
'fix-dependencies',
'validate-dependencies',
'setup-models',
'view-models',
'install-taskmaster',
'quick-install-taskmaster',
'to-review',
'to-deferred',
'to-cancelled',
'init-project',
'init-project-quick'
];
for (const name of knownSolo) {
const cmd = allCommands.find((c) => c.metadata.name === name);
expect(cmd).toBeDefined();
expect(cmd?.metadata.mode).toBe('solo');
}
});
it('common commands are tagged correctly or have undefined mode', () => {
// Known common commands - they should be 'common' or undefined (backward compat)
const knownCommon = [
'show-task',
'list-tasks',
'list-tasks-with-subtasks',
'list-tasks-by-status',
'project-status',
'next-task',
'help',
'to-done',
'to-pending',
'to-in-progress',
'update-task',
'update-single-task',
'update-tasks-from-id',
'tm-main',
'smart-workflow',
'learn',
'command-pipeline',
'auto-implement-tasks',
'analyze-project',
'sync-readme'
];
for (const name of knownCommon) {
const cmd = allCommands.find((c) => c.metadata.name === name);
expect(cmd).toBeDefined();
// Common commands can be explicitly 'common' or undefined (backward compat)
const mode = cmd?.metadata.mode;
expect(mode === 'common' || mode === undefined).toBe(true);
}
});
});
describe('Edge cases', () => {
it('filtering empty array returns empty array', () => {
// Act
const soloFiltered = filterCommandsByMode([], 'solo');
const teamFiltered = filterCommandsByMode([], 'team');
// Assert
expect(soloFiltered).toHaveLength(0);
expect(teamFiltered).toHaveLength(0);
});
it('total commands equals solo + team + common (no overlap)', () => {
// This verifies our categorization is complete and non-overlapping
const soloCount = allCommands.filter(
(cmd) => cmd.metadata.mode === 'solo'
).length;
const teamCount = allCommands.filter(
(cmd) => cmd.metadata.mode === 'team'
).length;
const commonCount = allCommands.filter(
(cmd) =>
cmd.metadata.mode === 'common' || cmd.metadata.mode === undefined
).length;
// Assert
expect(soloCount + teamCount + commonCount).toBe(allCommands.length);
});
});
});

View File

@@ -0,0 +1,73 @@
/**
* @fileoverview Add Dependency Slash Command
* Add a dependency between tasks.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The add-dependency slash command - Add Dependency
*
* Add a dependency between tasks.
*/
export const addDependency = dynamicCommand(
'add-dependency',
'Add Dependency',
'<task-id> <depends-on-id>',
`Add a dependency between tasks.
Arguments: $ARGUMENTS
Parse the task IDs to establish dependency relationship.
## Adding Dependencies
Creates a dependency where one task must be completed before another can start.
## Argument Parsing
Parse natural language or IDs:
- "make 5 depend on 3" → task 5 depends on task 3
- "5 needs 3" → task 5 depends on task 3
- "5 3" → task 5 depends on task 3
- "5 after 3" → task 5 depends on task 3
## Execution
\`\`\`bash
task-master add-dependency --id=<task-id> --depends-on=<dependency-id>
\`\`\`
## Validation
Before adding:
1. **Verify both tasks exist**
2. **Check for circular dependencies**
3. **Ensure dependency makes logical sense**
4. **Warn if creating complex chains**
## Smart Features
- Detect if dependency already exists
- Suggest related dependencies
- Show impact on task flow
- Update task priorities if needed
## Post-Addition
After adding dependency:
1. Show updated dependency graph
2. Identify any newly blocked tasks
3. Suggest task order changes
4. Update project timeline
## Example Flows
\`\`\`
/taskmaster:add-dependency 5 needs 3
→ Task #5 now depends on Task #3
→ Task #5 is now blocked until #3 completes
→ Suggested: Also consider if #5 needs #4
\`\`\``,
'solo'
);

View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Add Subtask Slash Command
* Add a subtask to a parent task.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The add-subtask slash command - Add Subtask
*
* Add a subtask to a parent task.
*/
export const addSubtask = dynamicCommand(
'add-subtask',
'Add Subtask',
'<parent-id> <title>',
`Add a subtask to a parent task.
Arguments: $ARGUMENTS
Parse arguments to create a new subtask or convert existing task.
## Adding Subtasks
Creates subtasks to break down complex parent tasks into manageable pieces.
## Argument Parsing
Flexible natural language:
- "add subtask to 5: implement login form"
- "break down 5 with: setup, implement, test"
- "subtask for 5: handle edge cases"
- "5: validate user input" → adds subtask to task 5
## Execution Modes
### 1. Create New Subtask
\`\`\`bash
task-master add-subtask --parent=<id> --title="<title>" --description="<desc>"
\`\`\`
### 2. Convert Existing Task
\`\`\`bash
task-master add-subtask --parent=<id> --task-id=<existing-id>
\`\`\`
## Smart Features
1. **Automatic Subtask Generation**
- If title contains "and" or commas, create multiple
- Suggest common subtask patterns
- Inherit parent's context
2. **Intelligent Defaults**
- Priority based on parent
- Appropriate time estimates
- Logical dependencies between subtasks
3. **Validation**
- Check parent task complexity
- Warn if too many subtasks
- Ensure subtask makes sense
## Creation Process
1. Parse parent task context
2. Generate subtask with ID like "5.1"
3. Set appropriate defaults
4. Link to parent task
5. Update parent's time estimate
## Example Flows
\`\`\`
/taskmaster:add-subtask to 5: implement user authentication
→ Created subtask #5.1: "implement user authentication"
→ Parent task #5 now has 1 subtask
→ Suggested next subtasks: tests, documentation
/taskmaster:add-subtask 5: setup, implement, test
→ Created 3 subtasks:
#5.1: setup
#5.2: implement
#5.3: test
\`\`\`
## Post-Creation
- Show updated task hierarchy
- Suggest logical next subtasks
- Update complexity estimates
- Recommend subtask order`,
'solo'
);

View File

@@ -0,0 +1,96 @@
/**
* @fileoverview Add Task Slash Command
* Add new tasks with intelligent parsing and context awareness.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The add-task slash command - Add Task
*
* Add new tasks with intelligent parsing and context awareness.
*/
export const addTask = dynamicCommand(
'add-task',
'Add Task',
'<description>',
`Add new tasks with intelligent parsing and context awareness.
Arguments: $ARGUMENTS
## Smart Task Addition
Parse natural language to create well-structured tasks.
### 1. **Input Understanding**
I'll intelligently parse your request:
- Natural language → Structured task
- Detect priority from keywords (urgent, ASAP, important)
- Infer dependencies from context
- Suggest complexity based on description
- Determine task type (feature, bug, refactor, test, docs)
### 2. **Smart Parsing Examples**
**"Add urgent task to fix login bug"**
→ Title: Fix login bug
→ Priority: high
→ Type: bug
→ Suggested complexity: medium
**"Create task for API documentation after task 23 is done"**
→ Title: API documentation
→ Dependencies: [23]
→ Type: documentation
→ Priority: medium
**"Need to refactor auth module - depends on 12 and 15, high complexity"**
→ Title: Refactor auth module
→ Dependencies: [12, 15]
→ Complexity: high
→ Type: refactor
### 3. **Context Enhancement**
Based on current project state:
- Suggest related existing tasks
- Warn about potential conflicts
- Recommend dependencies
- Propose subtasks if complex
### 4. **Interactive Refinement**
\`\`\`yaml
Task Preview:
─────────────
Title: [Extracted title]
Priority: [Inferred priority]
Dependencies: [Detected dependencies]
Complexity: [Estimated complexity]
Suggestions:
- Similar task #34 exists, consider as dependency?
- This seems complex, break into subtasks?
- Tasks #45-47 work on same module
\`\`\`
### 5. **Validation & Creation**
Before creating:
- Validate dependencies exist
- Check for duplicates
- Ensure logical ordering
- Verify task completeness
### 6. **Smart Defaults**
Intelligent defaults based on:
- Task type patterns
- Team conventions
- Historical data
- Current sprint/phase
Result: High-quality tasks from minimal input.`,
'solo'
);

View File

@@ -0,0 +1,139 @@
/**
* @fileoverview Analyze Complexity Slash Command
* Analyze task complexity and generate expansion recommendations.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The analyze-complexity slash command - Analyze Complexity
*
* Analyze task complexity and generate expansion recommendations.
*/
export const analyzeComplexity = dynamicCommand(
'analyze-complexity',
'Analyze Complexity',
'[options]',
`Analyze task complexity and generate expansion recommendations.
Arguments: $ARGUMENTS
Perform deep analysis of task complexity across the project.
## Complexity Analysis
Uses AI to analyze tasks and recommend which ones need breakdown.
## Execution Options
\`\`\`bash
task-master analyze-complexity [--research] [--threshold=5]
\`\`\`
## Analysis Parameters
- \`--research\` → Use research AI for deeper analysis
- \`--threshold=5\` → Only flag tasks above complexity 5
- Default: Analyze all pending tasks
## Analysis Process
### 1. **Task Evaluation**
For each task, AI evaluates:
- Technical complexity
- Time requirements
- Dependency complexity
- Risk factors
- Knowledge requirements
### 2. **Complexity Scoring**
Assigns score 1-10 based on:
- Implementation difficulty
- Integration challenges
- Testing requirements
- Unknown factors
- Technical debt risk
### 3. **Recommendations**
For complex tasks:
- Suggest expansion approach
- Recommend subtask breakdown
- Identify risk areas
- Propose mitigation strategies
## Smart Analysis Features
1. **Pattern Recognition**
- Similar task comparisons
- Historical complexity accuracy
- Team velocity consideration
- Technology stack factors
2. **Contextual Factors**
- Team expertise
- Available resources
- Timeline constraints
- Business criticality
3. **Risk Assessment**
- Technical risks
- Timeline risks
- Dependency risks
- Knowledge gaps
## Output Format
\`\`\`
Task Complexity Analysis Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
High Complexity Tasks (>7):
📍 #5 "Implement real-time sync" - Score: 9/10
Factors: WebSocket complexity, state management, conflict resolution
Recommendation: Expand into 5-7 subtasks
Risks: Performance, data consistency
📍 #12 "Migrate database schema" - Score: 8/10
Factors: Data migration, zero downtime, rollback strategy
Recommendation: Expand into 4-5 subtasks
Risks: Data loss, downtime
Medium Complexity Tasks (5-7):
📍 #23 "Add export functionality" - Score: 6/10
Consider expansion if timeline tight
Low Complexity Tasks (<5):
✅ 15 tasks - No expansion needed
Summary:
- Expand immediately: 2 tasks
- Consider expanding: 5 tasks
- Keep as-is: 15 tasks
\`\`\`
## Actionable Output
For each high-complexity task:
1. Complexity score with reasoning
2. Specific expansion suggestions
3. Risk mitigation approaches
4. Recommended subtask structure
## Integration
Results are:
- Saved to \`.taskmaster/reports/complexity-analysis.md\`
- Used by expand command
- Inform sprint planning
- Guide resource allocation
## Next Steps
After analysis:
\`\`\`
/taskmaster:expand 5 # Expand specific task
/taskmaster:expand-all # Expand all recommended
/taskmaster:complexity-report # View detailed report
\`\`\``,
'solo'
);

View File

@@ -0,0 +1,135 @@
/**
* @fileoverview Complexity Report Slash Command
* Display the task complexity analysis report.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The complexity-report slash command - Complexity Report
*
* Display the task complexity analysis report.
*/
export const complexityReport = dynamicCommand(
'complexity-report',
'Complexity Report',
'[--file=<path>]',
`Display the task complexity analysis report.
Arguments: $ARGUMENTS
View the detailed complexity analysis generated by analyze-complexity command.
## Viewing Complexity Report
Shows comprehensive task complexity analysis with actionable insights.
## Execution
\`\`\`bash
task-master complexity-report [--file=<path>]
\`\`\`
## Report Location
Default: \`.taskmaster/reports/complexity-analysis.md\`
Custom: Specify with --file parameter
## Report Contents
### 1. **Executive Summary**
\`\`\`
Complexity Analysis Summary
━━━━━━━━━━━━━━━━━━━━━━━━
Analysis Date: 2024-01-15
Tasks Analyzed: 32
High Complexity: 5 (16%)
Medium Complexity: 12 (37%)
Low Complexity: 15 (47%)
Critical Findings:
- 5 tasks need immediate expansion
- 3 tasks have high technical risk
- 2 tasks block critical path
\`\`\`
### 2. **Detailed Task Analysis**
For each complex task:
- Complexity score breakdown
- Contributing factors
- Specific risks identified
- Expansion recommendations
- Similar completed tasks
### 3. **Risk Matrix**
Visual representation:
\`\`\`
Risk vs Complexity Matrix
━━━━━━━━━━━━━━━━━━━━━━━
High Risk | #5(9) #12(8) | #23(6)
Med Risk | #34(7) | #45(5) #67(5)
Low Risk | #78(8) | [15 tasks]
| High Complex | Med Complex
\`\`\`
### 4. **Recommendations**
**Immediate Actions:**
1. Expand task #5 - Critical path + high complexity
2. Expand task #12 - High risk + dependencies
3. Review task #34 - Consider splitting
**Sprint Planning:**
- Don't schedule multiple high-complexity tasks together
- Ensure expertise available for complex tasks
- Build in buffer time for unknowns
## Interactive Features
When viewing report:
1. **Quick Actions**
- Press 'e' to expand a task
- Press 'd' for task details
- Press 'r' to refresh analysis
2. **Filtering**
- View by complexity level
- Filter by risk factors
- Show only actionable items
3. **Export Options**
- Markdown format
- CSV for spreadsheets
- JSON for tools
## Report Intelligence
- Compares with historical data
- Shows complexity trends
- Identifies patterns
- Suggests process improvements
## Integration
Use report for:
- Sprint planning sessions
- Resource allocation
- Risk assessment
- Team discussions
- Client updates
## Example Usage
\`\`\`
/taskmaster:complexity-report
→ Opens latest analysis
/taskmaster:complexity-report --file=archived/2024-01-01.md
→ View historical analysis
After viewing:
/taskmaster:expand 5
→ Expand high-complexity task
\`\`\``,
'solo'
);

View File

@@ -0,0 +1,89 @@
/**
* @fileoverview Convert Task To Subtask Slash Command
* Convert an existing task into a subtask.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The convert-task-to-subtask slash command - Convert Task To Subtask
*
* Convert an existing task into a subtask.
*/
export const convertTaskToSubtask = dynamicCommand(
'convert-task-to-subtask',
'Convert Task To Subtask',
'<parent-id> <task-id>',
`Convert an existing task into a subtask.
Arguments: $ARGUMENTS
Parse parent ID and task ID to convert.
## Task Conversion
Converts an existing standalone task into a subtask of another task.
## Argument Parsing
- "move task 8 under 5"
- "make 8 a subtask of 5"
- "nest 8 in 5"
- "5 8" → make task 8 a subtask of task 5
## Execution
\`\`\`bash
task-master add-subtask --parent=<parent-id> --task-id=<task-to-convert>
\`\`\`
## Pre-Conversion Checks
1. **Validation**
- Both tasks exist and are valid
- No circular parent relationships
- Task isn't already a subtask
- Logical hierarchy makes sense
2. **Impact Analysis**
- Dependencies that will be affected
- Tasks that depend on converting task
- Priority alignment needed
- Status compatibility
## Conversion Process
1. Change task ID from "8" to "5.1" (next available)
2. Update all dependency references
3. Inherit parent's context where appropriate
4. Adjust priorities if needed
5. Update time estimates
## Smart Features
- Preserve task history
- Maintain dependencies
- Update all references
- Create conversion log
## Example
\`\`\`
/taskmaster:add-subtask/from-task 5 8
→ Converting: Task #8 becomes subtask #5.1
→ Updated: 3 dependency references
→ Parent task #5 now has 1 subtask
→ Note: Subtask inherits parent's priority
Before: #8 "Implement validation" (standalone)
After: #5.1 "Implement validation" (subtask of #5)
\`\`\`
## Post-Conversion
- Show new task hierarchy
- List updated dependencies
- Verify project integrity
- Suggest related conversions`,
'solo'
);

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview Expand All Tasks Slash Command
* Bulk expansion of all pending tasks that need subtasks.
*/
import { staticCommand } from '../../factories.js';
/**
* The expand-all-tasks slash command - Expand All Tasks
*
* Bulk expansion of all pending tasks that need subtasks.
*/
export const expandAllTasks = staticCommand({
name: 'expand-all-tasks',
description: 'Expand All Tasks',
content: `Expand all pending tasks that need subtasks.
## Bulk Task Expansion
Intelligently expands all tasks that would benefit from breakdown.
## Execution
\`\`\`bash
task-master expand --all
\`\`\`
## Smart Selection
Only expands tasks that:
- Are marked as pending
- Have high complexity (>5)
- Lack existing subtasks
- Would benefit from breakdown
## Expansion Process
1. **Analysis Phase**
- Identify expansion candidates
- Group related tasks
- Plan expansion strategy
2. **Batch Processing**
- Expand tasks in logical order
- Maintain consistency
- Preserve relationships
- Optimize for parallelism
3. **Quality Control**
- Ensure subtask quality
- Avoid over-decomposition
- Maintain task coherence
- Update dependencies
## Options
- Add \`force\` to expand all regardless of complexity
- Add \`research\` for enhanced AI analysis
## Results
After bulk expansion:
- Summary of tasks expanded
- New subtask count
- Updated complexity metrics
- Suggested task order`,
mode: 'solo'
});

View File

@@ -0,0 +1,67 @@
/**
* @fileoverview Expand Task Slash Command
* Break down a complex task into subtasks.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The expand-task slash command - Expand Task
*
* Break down a complex task into subtasks.
*/
export const expandTask = dynamicCommand(
'expand-task',
'Expand Task',
'<task-id>',
`Break down a complex task into subtasks.
Arguments: $ARGUMENTS (task ID)
## Intelligent Task Expansion
Analyzes a task and creates detailed subtasks for better manageability.
## Execution
\`\`\`bash
task-master expand --id=$ARGUMENTS
\`\`\`
## Expansion Process
1. **Task Analysis**
- Review task complexity
- Identify components
- Detect technical challenges
- Estimate time requirements
2. **Subtask Generation**
- Create 3-7 subtasks typically
- Each subtask 1-4 hours
- Logical implementation order
- Clear acceptance criteria
3. **Smart Breakdown**
- Setup/configuration tasks
- Core implementation
- Testing components
- Integration steps
- Documentation updates
## Enhanced Features
Based on task type:
- **Feature**: Setup → Implement → Test → Integrate
- **Bug Fix**: Reproduce → Diagnose → Fix → Verify
- **Refactor**: Analyze → Plan → Refactor → Validate
## Post-Expansion
After expansion:
1. Show subtask hierarchy
2. Update time estimates
3. Suggest implementation order
4. Highlight critical path`,
'solo'
);

View File

@@ -0,0 +1,98 @@
/**
* @fileoverview Fix Dependencies Slash Command
* Automatically fix dependency issues found during validation.
*/
import { staticCommand } from '../../factories.js';
/**
* The fix-dependencies slash command - Fix Dependencies
*
* Automatically fix dependency issues found during validation.
*/
export const fixDependencies = staticCommand({
name: 'fix-dependencies',
description: 'Fix Dependencies',
content: `Automatically fix dependency issues found during validation.
## Automatic Dependency Repair
Intelligently fixes common dependency problems while preserving project logic.
## Execution
\`\`\`bash
task-master fix-dependencies
\`\`\`
## What Gets Fixed
### 1. **Auto-Fixable Issues**
- Remove references to deleted tasks
- Break simple circular dependencies
- Remove self-dependencies
- Clean up duplicate dependencies
### 2. **Smart Resolutions**
- Reorder dependencies to maintain logic
- Suggest task merging for over-dependent tasks
- Flatten unnecessary dependency chains
- Remove redundant transitive dependencies
### 3. **Manual Review Required**
- Complex circular dependencies
- Critical path modifications
- Business logic dependencies
- High-impact changes
## Fix Process
1. **Analysis Phase**
- Run validation check
- Categorize issues by type
- Determine fix strategy
2. **Execution Phase**
- Apply automatic fixes
- Log all changes made
- Preserve task relationships
3. **Verification Phase**
- Re-validate after fixes
- Show before/after comparison
- Highlight manual fixes needed
## Smart Features
- Preserves intended task flow
- Minimal disruption approach
- Creates fix history/log
- Suggests manual interventions
## Output Example
\`\`\`
Dependency Auto-Fix Report
━━━━━━━━━━━━━━━━━━━━━━━━
Fixed Automatically:
✅ Removed 2 references to deleted tasks
✅ Resolved 1 self-dependency
✅ Cleaned 3 redundant dependencies
Manual Review Needed:
⚠️ Complex circular dependency: #12 → #15 → #18 → #12
Suggestion: Make #15 not depend on #12
⚠️ Task #45 has 8 dependencies
Suggestion: Break into subtasks
Run '/taskmaster:validate-dependencies' to verify fixes
\`\`\`
## Safety
- Preview mode available
- Rollback capability
- Change logging
- No data loss`,
mode: 'solo'
});

View File

@@ -0,0 +1,130 @@
/**
* @fileoverview Generate Tasks Slash Command
* Generate individual task files from tasks.json.
*/
import { staticCommand } from '../../factories.js';
/**
* The generate-tasks slash command - Generate Task Files
*
* Creates separate markdown files for each task.
*/
export const generateTasks = staticCommand({
name: 'generate-tasks',
description: 'Generate Task Files',
content: `Generate individual task files from tasks.json.
## Task File Generation
Creates separate markdown files for each task, perfect for AI agents or documentation.
## Execution
\`\`\`bash
task-master generate
\`\`\`
## What It Creates
For each task, generates a file like \`task_001.md\`:
\`\`\`
Task ID: 1
Title: Implement user authentication
Status: pending
Priority: high
Dependencies: []
Created: 2024-01-15
Complexity: 7
## Description
Create a secure user authentication system with login, logout, and session management.
## Details
- Use JWT tokens for session management
- Implement secure password hashing
- Add remember me functionality
- Include password reset flow
## Test Strategy
- Unit tests for auth functions
- Integration tests for login flow
- Security testing for vulnerabilities
- Performance tests for concurrent logins
## Subtasks
1.1 Setup authentication framework (pending)
1.2 Create login endpoints (pending)
1.3 Implement session management (pending)
1.4 Add password reset (pending)
\`\`\`
## File Organization
Creates structure:
\`\`\`
.taskmaster/
└── tasks/
├── task_001.md
├── task_002.md
├── task_003.md
└── ...
\`\`\`
## Smart Features
1. **Consistent Formatting**
- Standardized structure
- Clear sections
- AI-readable format
- Markdown compatible
2. **Contextual Information**
- Full task details
- Related task references
- Progress indicators
- Implementation notes
3. **Incremental Updates**
- Only regenerate changed tasks
- Preserve custom additions
- Track generation timestamp
- Version control friendly
## Use Cases
- **AI Context**: Provide task context to AI assistants
- **Documentation**: Standalone task documentation
- **Archival**: Task history preservation
- **Sharing**: Send specific tasks to team members
- **Review**: Easier task review process
## Post-Generation
\`\`\`
Task File Generation Complete
━━━━━━━━━━━━━━━━━━━━━━━━━━
Generated: 45 task files
Location: .taskmaster/tasks/
Total size: 156 KB
New files: 5
Updated files: 12
Unchanged: 28
Ready for:
- AI agent consumption
- Version control
- Team distribution
\`\`\`
## Integration Benefits
- Git-trackable task history
- Easy task sharing
- AI tool compatibility
- Offline task access
- Backup redundancy`,
mode: 'solo'
});

View File

@@ -0,0 +1,49 @@
/**
* @fileoverview Solo Mode Commands
* Commands that only work with local file-based storage (Taskmaster standalone).
*/
// PRD parsing
export { parsePrd } from './parse-prd.js';
export { parsePrdWithResearch } from './parse-prd-with-research.js';
// Analysis
export { analyzeComplexity } from './analyze-complexity.js';
export { complexityReport } from './complexity-report.js';
// Task expansion
export { expandTask } from './expand-task.js';
export { expandAllTasks } from './expand-all-tasks.js';
// Task mutation
export { addTask } from './add-task.js';
export { addSubtask } from './add-subtask.js';
export { removeTask } from './remove-task.js';
export { removeSubtask } from './remove-subtask.js';
export { removeSubtasks } from './remove-subtasks.js';
export { removeAllSubtasks } from './remove-all-subtasks.js';
export { convertTaskToSubtask } from './convert-task-to-subtask.js';
// Dependencies
export { addDependency } from './add-dependency.js';
export { removeDependency } from './remove-dependency.js';
export { fixDependencies } from './fix-dependencies.js';
export { validateDependencies } from './validate-dependencies.js';
// Configuration
export { setupModels } from './setup-models.js';
export { viewModels } from './view-models.js';
export { installTaskmaster } from './install-taskmaster.js';
export { quickInstallTaskmaster } from './quick-install-taskmaster.js';
// Status (solo-only)
export { toReview } from './to-review.js';
export { toDeferred } from './to-deferred.js';
export { toCancelled } from './to-cancelled.js';
// Init
export { initProject } from './init-project.js';
export { initProjectQuick } from './init-project-quick.js';
// Generation
export { generateTasks } from './generate-tasks.js';

View File

@@ -0,0 +1,64 @@
/**
* @fileoverview Init Project Quick Slash Command
* Quick initialization with auto-confirmation.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The init-project-quick slash command - Init Project Quick
*
* Quick initialization with auto-confirmation.
*/
export const initProjectQuick = dynamicCommand(
'init-project-quick',
'Init Project Quick',
'[prd-file]',
`Quick initialization with auto-confirmation.
Arguments: $ARGUMENTS
Initialize a Task Master project without prompts, accepting all defaults.
## Quick Setup
\`\`\`bash
task-master init -y
\`\`\`
## What It Does
1. Creates \`.taskmaster/\` directory structure
2. Initializes empty \`tasks.json\`
3. Sets up default configuration
4. Uses directory name as project name
5. Skips all confirmation prompts
## Smart Defaults
- Project name: Current directory name
- Description: "Task Master Project"
- Model config: Existing environment vars
- Task structure: Standard format
## Next Steps
After quick init:
1. Configure AI models if needed:
\`\`\`
/taskmaster:models/setup
\`\`\`
2. Parse PRD if available:
\`\`\`
/taskmaster:parse-prd <file>
\`\`\`
3. Or create first task:
\`\`\`
/taskmaster:add-task create initial setup
\`\`\`
Perfect for rapid project setup!`,
'solo'
);

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview Init Project Slash Command
* Initialize a new Task Master project.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The init-project slash command - Init Project
*
* Initialize a new Task Master project.
*/
export const initProject = dynamicCommand(
'init-project',
'Init Project',
'[prd-file]',
`Initialize a new Task Master project.
Arguments: $ARGUMENTS
Parse arguments to determine initialization preferences.
## Initialization Process
1. **Parse Arguments**
- PRD file path (if provided)
- Project name
- Auto-confirm flag (-y)
2. **Project Setup**
\`\`\`bash
task-master init
\`\`\`
3. **Smart Initialization**
- Detect existing project files
- Suggest project name from directory
- Check for git repository
- Verify AI provider configuration
## Configuration Options
Based on arguments:
- \`quick\` / \`-y\` → Skip confirmations
- \`<file.md>\` → Use as PRD after init
- \`--name=<name>\` → Set project name
- \`--description=<desc>\` → Set description
## Post-Initialization
After successful init:
1. Show project structure created
2. Verify AI models configured
3. Suggest next steps:
- Parse PRD if available
- Configure AI providers
- Set up git hooks
- Create first tasks
## Integration
If PRD file provided:
\`\`\`
/taskmaster:init my-prd.md
→ Automatically runs parse-prd after init
\`\`\``,
'solo'
);

View File

@@ -0,0 +1,134 @@
/**
* @fileoverview Install TaskMaster Slash Command
* Check if Task Master is installed and install it if needed.
*/
import { staticCommand } from '../../factories.js';
/**
* The install-taskmaster slash command - Install TaskMaster
*
* Check if Task Master is installed and install it if needed.
*/
export const installTaskmaster = staticCommand({
name: 'install-taskmaster',
description: 'Install TaskMaster',
content: `Check if Task Master is installed and install it if needed.
This command helps you get Task Master set up globally on your system.
## Detection and Installation Process
1. **Check Current Installation**
\`\`\`bash
# Check if task-master command exists
which task-master || echo "Task Master not found"
# Check npm global packages
npm list -g task-master-ai
\`\`\`
2. **System Requirements Check**
\`\`\`bash
# Verify Node.js is installed
node --version
# Verify npm is installed
npm --version
# Check Node version (need 16+)
\`\`\`
3. **Install Task Master Globally**
If not installed, run:
\`\`\`bash
npm install -g task-master-ai
\`\`\`
4. **Verify Installation**
\`\`\`bash
# Check version
task-master --version
# Verify command is available
which task-master
\`\`\`
5. **Initial Setup**
\`\`\`bash
# Initialize in current directory
task-master init
\`\`\`
6. **Configure AI Provider**
Ensure you have at least one AI provider API key set:
\`\`\`bash
# Check current configuration
task-master models --status
# If no API keys found, guide setup
echo "You'll need at least one API key:"
echo "- ANTHROPIC_API_KEY for Claude"
echo "- OPENAI_API_KEY for GPT models"
echo "- PERPLEXITY_API_KEY for research"
echo ""
echo "Set them in your shell profile or .env file"
\`\`\`
7. **Quick Test**
\`\`\`bash
# Create a test PRD
echo "Build a simple hello world API" > test-prd.txt
# Try parsing it
task-master parse-prd test-prd.txt -n 3
\`\`\`
## Troubleshooting
If installation fails:
**Permission Errors:**
\`\`\`bash
# Try with sudo (macOS/Linux)
sudo npm install -g task-master-ai
# Or fix npm permissions
npm config set prefix ~/.npm-global
export PATH=~/.npm-global/bin:$PATH
\`\`\`
**Network Issues:**
\`\`\`bash
# Use different registry
npm install -g task-master-ai --registry https://registry.npmjs.org/
\`\`\`
**Node Version Issues:**
\`\`\`bash
# Install Node 20+ via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
nvm use 20
\`\`\`
## Success Confirmation
Once installed, you should see:
\`\`\`
✅ Task Master installed
✅ Command 'task-master' available globally
✅ AI provider configured
✅ Ready to use slash commands!
Try: /taskmaster:init your-prd.md
\`\`\`
## Next Steps
After installation:
1. Run \`/taskmaster:status\` to verify setup
2. Configure AI providers with \`/taskmaster:setup-models\`
3. Start using Task Master commands!`,
mode: 'solo'
});

View File

@@ -0,0 +1,66 @@
/**
* @fileoverview Parse PRD With Research Slash Command
* Parse PRD with enhanced research mode for better task generation.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The parse-prd-with-research slash command - Parse PRD With Research
*
* Parse PRD with enhanced research mode for better task generation.
*/
export const parsePrdWithResearch = dynamicCommand(
'parse-prd-with-research',
'Parse PRD With Research',
'<prd-file>',
`Parse PRD with enhanced research mode for better task generation.
Arguments: $ARGUMENTS (PRD file path)
## Research-Enhanced Parsing
Uses the research AI provider (typically Perplexity) for more comprehensive task generation with current best practices.
## Execution
\`\`\`bash
task-master parse-prd --input=$ARGUMENTS --research
\`\`\`
## Research Benefits
1. **Current Best Practices**
- Latest framework patterns
- Security considerations
- Performance optimizations
- Accessibility requirements
2. **Technical Deep Dive**
- Implementation approaches
- Library recommendations
- Architecture patterns
- Testing strategies
3. **Comprehensive Coverage**
- Edge cases consideration
- Error handling tasks
- Monitoring setup
- Deployment tasks
## Enhanced Output
Research mode typically:
- Generates more detailed tasks
- Includes industry standards
- Adds compliance considerations
- Suggests modern tooling
## When to Use
- New technology domains
- Complex requirements
- Regulatory compliance needed
- Best practices crucial`,
'solo'
);

View File

@@ -0,0 +1,67 @@
/**
* @fileoverview Parse PRD Slash Command
* Parse a PRD document to generate tasks.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The parse-prd slash command - Parse PRD
*
* Parse a PRD document to generate tasks.
*/
export const parsePrd = dynamicCommand(
'parse-prd',
'Parse PRD',
'<prd-file>',
`Parse a PRD document to generate tasks.
Arguments: $ARGUMENTS (PRD file path)
## Intelligent PRD Parsing
Analyzes your requirements document and generates a complete task breakdown.
## Execution
\`\`\`bash
task-master parse-prd --input=$ARGUMENTS
\`\`\`
## Parsing Process
1. **Document Analysis**
- Extract key requirements
- Identify technical components
- Detect dependencies
- Estimate complexity
2. **Task Generation**
- Create 10-15 tasks by default
- Include implementation tasks
- Add testing tasks
- Include documentation tasks
- Set logical dependencies
3. **Smart Enhancements**
- Group related functionality
- Set appropriate priorities
- Add acceptance criteria
- Include test strategies
## Options
Parse arguments for modifiers:
- Number after filename → \`--num-tasks\`
- \`research\` → Use research mode
- \`comprehensive\` → Generate more tasks
## Post-Generation
After parsing:
1. Display task summary
2. Show dependency graph
3. Suggest task expansion for complex items
4. Recommend sprint planning`,
'solo'
);

View File

@@ -0,0 +1,39 @@
/**
* @fileoverview Quick Install TaskMaster Slash Command
* Quick install Task Master globally if not already installed.
*/
import { staticCommand } from '../../factories.js';
/**
* The quick-install-taskmaster slash command - Quick Install TaskMaster
*
* Quick install Task Master globally if not already installed.
*/
export const quickInstallTaskmaster = staticCommand({
name: 'quick-install-taskmaster',
description: 'Quick Install TaskMaster',
content: `Quick install Task Master globally if not already installed.
Execute this streamlined installation:
\`\`\`bash
# Check and install in one command
task-master --version 2>/dev/null || npm install -g task-master-ai
# Verify installation
task-master --version
# Quick setup check
task-master models --status || echo "Note: You'll need to set up an AI provider API key"
\`\`\`
If you see "command not found" after installation, you may need to:
1. Restart your terminal
2. Or add npm global bin to PATH: \`export PATH=$(npm bin -g):$PATH\`
Once installed, you can use all the Task Master commands!
Quick test: Run \`/taskmaster:help\` to see all available commands.`,
mode: 'solo'
});

View File

@@ -0,0 +1,110 @@
/**
* @fileoverview Remove All Subtasks Slash Command
* Clear all subtasks from all tasks globally.
*/
import { staticCommand } from '../../factories.js';
/**
* The remove-all-subtasks slash command - Remove All Subtasks
*
* Clear all subtasks from all tasks globally.
*/
export const removeAllSubtasks = staticCommand({
name: 'remove-all-subtasks',
description: 'Remove All Subtasks',
content: `Clear all subtasks from all tasks globally.
## Global Subtask Clearing
Remove all subtasks across the entire project. Use with extreme caution.
## Execution
\`\`\`bash
task-master clear-subtasks --all
\`\`\`
## Pre-Clear Analysis
1. **Project-Wide Summary**
\`\`\`
Global Subtask Summary
━━━━━━━━━━━━━━━━━━━━
Total parent tasks: 12
Total subtasks: 47
- Completed: 15
- In-progress: 8
- Pending: 24
Work at risk: ~120 hours
\`\`\`
2. **Critical Warnings**
- In-progress subtasks that will lose work
- Completed subtasks with valuable history
- Complex dependency chains
- Integration test results
## Double Confirmation
\`\`\`
⚠️ DESTRUCTIVE OPERATION WARNING ⚠️
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
This will remove ALL 47 subtasks from your project
Including 8 in-progress and 15 completed subtasks
This action CANNOT be undone
Type 'CLEAR ALL SUBTASKS' to confirm:
\`\`\`
## Smart Safeguards
- Require explicit confirmation phrase
- Create automatic backup
- Log all removed data
- Option to export first
## Use Cases
Valid reasons for global clear:
- Project restructuring
- Major pivot in approach
- Starting fresh breakdown
- Switching to different task organization
## Process
1. Full project analysis
2. Create backup file
3. Show detailed impact
4. Require confirmation
5. Execute removal
6. Generate summary report
## Alternative Suggestions
Before clearing all:
- Export subtasks to file
- Clear only pending subtasks
- Clear by task category
- Archive instead of delete
## Post-Clear Report
\`\`\`
Global Subtask Clear Complete
━━━━━━━━━━━━━━━━━━━━━━━━━━━
Removed: 47 subtasks from 12 tasks
Backup saved: .taskmaster/backup/subtasks-20240115.json
Parent tasks updated: 12
Time estimates adjusted: Yes
Next steps:
- Review updated task list
- Re-expand complex tasks as needed
- Check project timeline
\`\`\``,
mode: 'solo'
});

View File

@@ -0,0 +1,80 @@
/**
* @fileoverview Remove Dependency Slash Command
* Remove a dependency between tasks.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The remove-dependency slash command - Remove Dependency
*
* Remove a dependency between tasks.
*/
export const removeDependency = dynamicCommand(
'remove-dependency',
'Remove Dependency',
'<task-id> <depends-on-id>',
`Remove a dependency between tasks.
Arguments: $ARGUMENTS
Parse the task IDs to remove dependency relationship.
## Removing Dependencies
Removes a dependency relationship, potentially unblocking tasks.
## Argument Parsing
Parse natural language or IDs:
- "remove dependency between 5 and 3"
- "5 no longer needs 3"
- "unblock 5 from 3"
- "5 3" → remove dependency of 5 on 3
## Execution
\`\`\`bash
task-master remove-dependency --id=<task-id> --depends-on=<dependency-id>
\`\`\`
## Pre-Removal Checks
1. **Verify dependency exists**
2. **Check impact on task flow**
3. **Warn if it breaks logical sequence**
4. **Show what will be unblocked**
## Smart Analysis
Before removing:
- Show why dependency might have existed
- Check if removal makes tasks executable
- Verify no critical path disruption
- Suggest alternative dependencies
## Post-Removal
After removing:
1. Show updated task status
2. List newly unblocked tasks
3. Update project timeline
4. Suggest next actions
## Safety Features
- Confirm if removing critical dependency
- Show tasks that become immediately actionable
- Warn about potential issues
- Keep removal history
## Example
\`\`\`
/taskmaster:remove-dependency 5 from 3
→ Removed: Task #5 no longer depends on #3
→ Task #5 is now UNBLOCKED and ready to start
→ Warning: Consider if #5 still needs #2 completed first
\`\`\``,
'solo'
);

View File

@@ -0,0 +1,102 @@
/**
* @fileoverview Remove Subtask Slash Command
* Remove a subtask from its parent task.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The remove-subtask slash command - Remove Subtask
*
* Remove a subtask from its parent task.
*/
export const removeSubtask = dynamicCommand(
'remove-subtask',
'Remove Subtask',
'<subtask-id>',
`Remove a subtask from its parent task.
Arguments: $ARGUMENTS
Parse subtask ID to remove, with option to convert to standalone task.
## Removing Subtasks
Remove a subtask and optionally convert it back to a standalone task.
## Argument Parsing
- "remove subtask 5.1"
- "delete 5.1"
- "convert 5.1 to task" → remove and convert
- "5.1 standalone" → convert to standalone
## Execution Options
### 1. Delete Subtask
\`\`\`bash
task-master remove-subtask --id=<parentId.subtaskId>
\`\`\`
### 2. Convert to Standalone
\`\`\`bash
task-master remove-subtask --id=<parentId.subtaskId> --convert
\`\`\`
## Pre-Removal Checks
1. **Validate Subtask**
- Verify subtask exists
- Check completion status
- Review dependencies
2. **Impact Analysis**
- Other subtasks that depend on it
- Parent task implications
- Data that will be lost
## Removal Process
### For Deletion:
1. Confirm if subtask has work done
2. Update parent task estimates
3. Remove subtask and its data
4. Clean up dependencies
### For Conversion:
1. Assign new standalone task ID
2. Preserve all task data
3. Update dependency references
4. Maintain task history
## Smart Features
- Warn if subtask is in-progress
- Show impact on parent task
- Preserve important data
- Update related estimates
## Example Flows
\`\`\`
/taskmaster:remove-subtask 5.1
→ Warning: Subtask #5.1 is in-progress
→ This will delete all subtask data
→ Parent task #5 will be updated
Confirm deletion? (y/n)
/taskmaster:remove-subtask 5.1 convert
→ Converting subtask #5.1 to standalone task #89
→ Preserved: All task data and history
→ Updated: 2 dependency references
→ New task #89 is now independent
\`\`\`
## Post-Removal
- Update parent task status
- Recalculate estimates
- Show updated hierarchy
- Suggest next actions`,
'solo'
);

View File

@@ -0,0 +1,104 @@
/**
* @fileoverview Remove Subtasks Slash Command
* Clear all subtasks from a specific task.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The remove-subtasks slash command - Remove Subtasks
*
* Clear all subtasks from a specific task.
*/
export const removeSubtasks = dynamicCommand(
'remove-subtasks',
'Remove Subtasks',
'<task-id>',
`Clear all subtasks from a specific task.
Arguments: $ARGUMENTS (task ID)
Remove all subtasks from a parent task at once.
## Clearing Subtasks
Bulk removal of all subtasks from a parent task.
## Execution
\`\`\`bash
task-master remove-subtasks --id=$ARGUMENTS
\`\`\`
## Pre-Clear Analysis
1. **Subtask Summary**
- Number of subtasks
- Completion status of each
- Work already done
- Dependencies affected
2. **Impact Assessment**
- Data that will be lost
- Dependencies to be removed
- Effect on project timeline
- Parent task implications
## Confirmation Required
\`\`\`
Remove Subtasks Confirmation
━━━━━━━━━━━━━━━━━━━━━━━━━
Parent Task: #5 "Implement user authentication"
Subtasks to remove: 4
- #5.1 "Setup auth framework" (done)
- #5.2 "Create login form" (in-progress)
- #5.3 "Add validation" (pending)
- #5.4 "Write tests" (pending)
⚠️ This will permanently delete all subtask data
Continue? (y/n)
\`\`\`
## Smart Features
- Option to convert to standalone tasks
- Backup task data before clearing
- Preserve completed work history
- Update parent task appropriately
## Process
1. List all subtasks for confirmation
2. Check for in-progress work
3. Remove all subtasks
4. Update parent task
5. Clean up dependencies
## Alternative Options
Suggest alternatives:
- Convert important subtasks to tasks
- Keep completed subtasks
- Archive instead of delete
- Export subtask data first
## Post-Clear
- Show updated parent task
- Recalculate time estimates
- Update task complexity
- Suggest next steps
## Example
\`\`\`
/taskmaster:remove-subtasks 5
→ Found 4 subtasks to remove
→ Warning: Subtask #5.2 is in-progress
→ Cleared all subtasks from task #5
→ Updated parent task estimates
→ Suggestion: Consider re-expanding with better breakdown
\`\`\``,
'solo'
);

View File

@@ -0,0 +1,125 @@
/**
* @fileoverview Remove Task Slash Command
* Remove a task permanently from the project.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The remove-task slash command - Remove Task
*
* Remove a task permanently from the project.
*/
export const removeTask = dynamicCommand(
'remove-task',
'Remove Task',
'<task-id>',
`Remove a task permanently from the project.
Arguments: $ARGUMENTS (task ID)
Delete a task and handle all its relationships properly.
## Task Removal
Permanently removes a task while maintaining project integrity.
## Argument Parsing
- "remove task 5"
- "delete 5"
- "5" → remove task 5
- Can include "-y" for auto-confirm
## Execution
\`\`\`bash
task-master remove-task --id=<id> [-y]
\`\`\`
## Pre-Removal Analysis
1. **Task Details**
- Current status
- Work completed
- Time invested
- Associated data
2. **Relationship Check**
- Tasks that depend on this
- Dependencies this task has
- Subtasks that will be removed
- Blocking implications
3. **Impact Assessment**
\`\`\`
Task Removal Impact
━━━━━━━━━━━━━━━━━━
Task: #5 "Implement authentication" (in-progress)
Status: 60% complete (~8 hours work)
Will affect:
- 3 tasks depend on this (will be blocked)
- Has 4 subtasks (will be deleted)
- Part of critical path
⚠️ This action cannot be undone
\`\`\`
## Smart Warnings
- Warn if task is in-progress
- Show dependent tasks that will be blocked
- Highlight if part of critical path
- Note any completed work being lost
## Removal Process
1. Show comprehensive impact
2. Require confirmation (unless -y)
3. Update dependent task references
4. Remove task and subtasks
5. Clean up orphaned dependencies
6. Log removal with timestamp
## Alternative Actions
Suggest before deletion:
- Mark as cancelled instead
- Convert to documentation
- Archive task data
- Transfer work to another task
## Post-Removal
- List affected tasks
- Show broken dependencies
- Update project statistics
- Suggest dependency fixes
- Recalculate timeline
## Example Flows
\`\`\`
/taskmaster:remove-task 5
→ Task #5 is in-progress with 8 hours logged
→ 3 other tasks depend on this
→ Suggestion: Mark as cancelled instead?
Remove anyway? (y/n)
/taskmaster:remove-task 5 -y
→ Removed: Task #5 and 4 subtasks
→ Updated: 3 task dependencies
→ Warning: Tasks #7, #8, #9 now have missing dependency
→ Run /taskmaster:fix-dependencies to resolve
\`\`\`
## Safety Features
- Confirmation required
- Impact preview
- Removal logging
- Suggest alternatives
- No cascade delete of dependents`,
'solo'
);

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview Setup Models Slash Command
* Run interactive setup to configure AI models.
*/
import { staticCommand } from '../../factories.js';
/**
* The setup-models slash command - Setup Models
*
* Run interactive setup to configure AI models.
*/
export const setupModels = staticCommand({
name: 'setup-models',
description: 'Setup Models',
content: `Run interactive setup to configure AI models.
## Interactive Model Configuration
Guides you through setting up AI providers for Task Master.
## Execution
\`\`\`bash
task-master models --setup
\`\`\`
## Setup Process
1. **Environment Check**
- Detect existing API keys
- Show current configuration
- Identify missing providers
2. **Provider Selection**
- Choose main provider (required)
- Select research provider (recommended)
- Configure fallback (optional)
3. **API Key Configuration**
- Prompt for missing keys
- Validate key format
- Test connectivity
- Save configuration
## Smart Recommendations
Based on your needs:
- **For best results**: Claude + Perplexity
- **Budget conscious**: GPT-3.5 + Perplexity
- **Maximum capability**: GPT-4 + Perplexity + Claude fallback
## Configuration Storage
Keys can be stored in:
1. Environment variables (recommended)
2. \`.env\` file in project
3. Global \`.taskmaster/config\`
## Post-Setup
After configuration:
- Test each provider
- Show usage examples
- Suggest next steps
- Verify parse-prd works`,
mode: 'solo'
});

View File

@@ -0,0 +1,73 @@
/**
* @fileoverview To Cancelled Slash Command
* Cancel a task permanently.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The to-cancelled slash command - To Cancelled
*
* Cancel a task permanently.
*/
export const toCancelled = dynamicCommand(
'to-cancelled',
'To Cancelled',
'<task-id>',
`Cancel a task permanently.
Arguments: $ARGUMENTS (task ID)
## Cancelling a Task
This status indicates a task is no longer needed and won't be completed.
## Valid Reasons for Cancellation
- Requirements changed
- Feature deprecated
- Duplicate of another task
- Strategic pivot
- Technical approach invalidated
## Pre-Cancellation Checks
1. Confirm no critical dependencies
2. Check for partial implementation
3. Verify cancellation rationale
4. Document lessons learned
## Execution
\`\`\`bash
task-master set-status --id=$ARGUMENTS --status=cancelled
\`\`\`
## Cancellation Impact
When cancelling:
1. **Dependency Updates**
- Notify dependent tasks
- Update project scope
- Recalculate timelines
2. **Clean-up Actions**
- Remove related branches
- Archive any work done
- Update documentation
- Close related issues
3. **Learning Capture**
- Document why cancelled
- Note what was learned
- Update estimation models
- Prevent future duplicates
## Historical Preservation
- Keep for reference
- Tag with cancellation reason
- Link to replacement if any
- Maintain audit trail`,
'solo'
);

View File

@@ -0,0 +1,65 @@
/**
* @fileoverview To Deferred Slash Command
* Defer a task for later consideration.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The to-deferred slash command - To Deferred
*
* Defer a task for later consideration.
*/
export const toDeferred = dynamicCommand(
'to-deferred',
'To Deferred',
'<task-id>',
`Defer a task for later consideration.
Arguments: $ARGUMENTS (task ID)
## Deferring a Task
This status indicates a task is valid but not currently actionable or prioritized.
## Valid Reasons for Deferral
- Waiting for external dependencies
- Reprioritized for future sprint
- Blocked by technical limitations
- Resource constraints
- Strategic timing considerations
## Execution
\`\`\`bash
task-master set-status --id=$ARGUMENTS --status=deferred
\`\`\`
## Deferral Management
When deferring:
1. **Document Reason**
- Capture why it's being deferred
- Set reactivation criteria
- Note any partial work completed
2. **Impact Analysis**
- Check dependent tasks
- Update project timeline
- Notify affected stakeholders
3. **Future Planning**
- Set review reminders
- Tag for specific milestone
- Preserve context for reactivation
- Link to blocking issues
## Smart Tracking
- Monitor deferral duration
- Alert when criteria met
- Prevent scope creep
- Regular review cycles`,
'solo'
);

View File

@@ -0,0 +1,58 @@
/**
* @fileoverview To Review Slash Command
* Set a task's status to review.
*/
import { dynamicCommand } from '../../factories.js';
/**
* The to-review slash command - To Review
*
* Set a task's status to review.
*/
export const toReview = dynamicCommand(
'to-review',
'To Review',
'<task-id>',
`Set a task's status to review.
Arguments: $ARGUMENTS (task ID)
## Marking Task for Review
This status indicates work is complete but needs verification before final approval.
## When to Use Review Status
- Code complete but needs peer review
- Implementation done but needs testing
- Documentation written but needs proofreading
- Design complete but needs stakeholder approval
## Execution
\`\`\`bash
task-master set-status --id=$ARGUMENTS --status=review
\`\`\`
## Review Preparation
When setting to review:
1. **Generate Review Checklist**
- Link to PR/MR if applicable
- Highlight key changes
- Note areas needing attention
- Include test results
2. **Documentation**
- Update task with review notes
- Link relevant artifacts
- Specify reviewers if known
3. **Smart Actions**
- Create review reminders
- Track review duration
- Suggest reviewers based on expertise
- Prepare rollback plan if needed`,
'solo'
);

View File

@@ -0,0 +1,88 @@
/**
* @fileoverview Validate Dependencies Slash Command
* Validate all task dependencies for issues.
*/
import { staticCommand } from '../../factories.js';
/**
* The validate-dependencies slash command - Validate Dependencies
*
* Validate all task dependencies for issues.
*/
export const validateDependencies = staticCommand({
name: 'validate-dependencies',
description: 'Validate Dependencies',
content: `Validate all task dependencies for issues.
## Dependency Validation
Comprehensive check for dependency problems across the entire project.
## Execution
\`\`\`bash
task-master validate-dependencies
\`\`\`
## Validation Checks
1. **Circular Dependencies**
- A depends on B, B depends on A
- Complex circular chains
- Self-dependencies
2. **Missing Dependencies**
- References to non-existent tasks
- Deleted task references
- Invalid task IDs
3. **Logical Issues**
- Completed tasks depending on pending
- Cancelled tasks in dependency chains
- Impossible sequences
4. **Complexity Warnings**
- Over-complex dependency chains
- Too many dependencies per task
- Bottleneck tasks
## Smart Analysis
The validation provides:
- Visual dependency graph
- Critical path analysis
- Bottleneck identification
- Suggested optimizations
## Report Format
\`\`\`
Dependency Validation Report
━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ No circular dependencies found
⚠️ 2 warnings found:
- Task #23 has 7 dependencies (consider breaking down)
- Task #45 blocks 5 other tasks (potential bottleneck)
❌ 1 error found:
- Task #67 depends on deleted task #66
Critical Path: #1 → #5 → #23 → #45 → #50 (15 days)
\`\`\`
## Actionable Output
For each issue found:
- Clear description
- Impact assessment
- Suggested fix
- Command to resolve
## Next Steps
After validation:
- Run \`/taskmaster:fix-dependencies\` to auto-fix
- Manually adjust problematic dependencies
- Rerun to verify fixes`,
mode: 'solo'
});

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview View Models Slash Command
* View current AI model configuration.
*/
import { staticCommand } from '../../factories.js';
/**
* The view-models slash command - View Models
*
* View current AI model configuration.
*/
export const viewModels = staticCommand({
name: 'view-models',
description: 'View Models',
content: `View current AI model configuration.
## Model Configuration Display
Shows the currently configured AI providers and models for Task Master.
## Execution
\`\`\`bash
task-master models
\`\`\`
## Information Displayed
1. **Main Provider**
- Model ID and name
- API key status (configured/missing)
- Usage: Primary task generation
2. **Research Provider**
- Model ID and name
- API key status
- Usage: Enhanced research mode
3. **Fallback Provider**
- Model ID and name
- API key status
- Usage: Backup when main fails
## Visual Status
\`\`\`
Task Master AI Model Configuration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Main: ✅ claude-3-5-sonnet (configured)
Research: ✅ perplexity-sonar (configured)
Fallback: ⚠️ Not configured (optional)
Available Models:
- claude-3-5-sonnet
- gpt-4-turbo
- gpt-3.5-turbo
- perplexity-sonar
\`\`\`
## Next Actions
Based on configuration:
- If missing API keys → Suggest setup
- If no research model → Explain benefits
- If all configured → Show usage tips`,
mode: 'solo'
});

View File

@@ -0,0 +1,344 @@
/**
* @fileoverview Goham Slash Command
* End-to-end workflow for working on tasks from a connected Hamster brief.
*/
import { staticCommand } from '../../factories.js';
/**
* The goham slash command - Start Working with Hamster Brief
*
* End-to-end workflow for working on tasks from a connected Hamster brief.
* All tasks from the brief are worked on in a single branch, with one PR created at the end.
*/
export const goham = staticCommand({
name: 'goham',
description: 'Start Working with Hamster Brief',
argumentHint: '[brief-url]',
mode: 'team',
content: `# Start Working with Hamster Brief
End-to-end workflow for working on tasks from a connected Hamster brief. All tasks from the brief are worked on in a single branch, with one PR created at the end.
## Step 1: Verify Connection & Authentication
\`\`\`bash
# Check current context and authentication status
tm context
\`\`\`
If not connected or authentication fails:
- Get brief URL from user if not available
- Connect: \`tm context <brief url>\`
- Refresh token if needed: \`tm auth refresh\`
## Step 2: List Available Tasks
\`\`\`bash
# View all tasks from the brief
tm list
\`\`\`
Review the task list to understand what needs to be done. Note the total number of tasks.
## Step 3: Initialize Git Branch for Brief
\`\`\`bash
# Ensure you're on dev branch and pull latest
git checkout dev
git pull origin dev
# Create a single branch for the entire brief (e.g., hamster-brief-YYYY-MM-DD or brief-specific name)
git checkout -b hamster-brief
# Verify branch creation
git branch
\`\`\`
**Note**: This branch will be used for ALL tasks in the brief. Do not create separate branches per task.
## Step 4: Task Loop (Repeat for Each Task)
Work through all tasks sequentially in the same branch:
### 4.1: Read Task Details
\`\`\`bash
# Get detailed information about the task
tm show 1
# If task has subtasks, examine them all
tm show 1,1.1,1.2,1.3 # Adjust IDs as needed
\`\`\`
### 4.2: Log Initial Context
\`\`\`bash
# Document task understanding and initial findings
tm update-task -i 1 --append --prompt="Starting task implementation.
Initial context:
- Task requirements: [summarize key requirements]
- Dependencies identified: [list any dependencies]
- Files that may need modification: [list relevant files]
- Approach planned: [brief implementation approach]"
\`\`\`
### 4.3: Mark Task as In-Progress
\`\`\`bash
# Mark task and first subtask (if exists) as in-progress
tm set-status -i 1,1.1 -s in-progress
\`\`\`
### 4.4: Subtask Implementation Loop
For each subtask (1.1, 1.2, 1.3, etc.):
#### 4.4.1: Read Subtask Details
\`\`\`bash
tm show 1.1 # Replace with current subtask ID
\`\`\`
#### 4.4.2: Log Research & Context Gathering
\`\`\`bash
# Document findings during implementation
tm update-task -i 1 --append --prompt="Subtask 1.1 - Context gathered:
- Code exploration findings: [what you discovered]
- Implementation approach: [how you plan to implement]
- Key decisions made: [important choices]
- Challenges encountered: [any blockers or issues]"
\`\`\`
#### 4.4.3: Implement Subtask
- Write code following the subtask requirements
- Make necessary changes to files
#### 4.4.4: Quality Verification
\`\`\`bash
# Run linting
pnpm lint
# Run type checking
pnpm typecheck
# If either fails, fix issues and re-run until both pass
\`\`\`
#### 4.4.5: CodeRabbit Review
\`\`\`bash
# Generate code review (wait for plain text results)
coderabbit --prompt-only
# Review the output and address any critical issues if needed
\`\`\`
#### 4.4.6: Log Implementation Completion
\`\`\`bash
# Document what was completed
tm update-task -i 1 --append --prompt="Subtask 1.1 - Implementation complete:
- Files modified: [list files changed]
- Key changes: [summary of implementation]
- CodeRabbit feedback addressed: [if any issues were fixed]
- Ready for commit"
\`\`\`
#### 4.4.7: Commit Subtask Work
\`\`\`bash
# Stage changes
git add .
# Commit with detailed message following git_workflow.mdc format
git commit -m "feat(task-1): Complete subtask 1.1 - [Subtask Title]
- Implementation details
- Key changes made
- Files modified: [list files]
- CodeRabbit review completed
Subtask 1.1: [Brief description of what was accomplished]
Relates to Task 1: [Main task title]"
\`\`\`
#### 4.4.8: Mark Subtask as Done
\`\`\`bash
tm set-status -i 1.1 -s done
\`\`\`
#### 4.4.9: Move to Next Subtask
Repeat steps 4.4.1 through 4.4.8 for the next subtask (1.2, 1.3, etc.)
### 4.5: Complete Parent Task
After all subtasks are complete:
#### 4.5.1: Final Quality Checks
\`\`\`bash
# Final linting
pnpm lint
# Final type checking
pnpm typecheck
# Final CodeRabbit review
coderabbit --prompt-only
# Address any remaining issues if critical
\`\`\`
#### 4.5.2: Log Task Completion
\`\`\`bash
# Document final task completion
tm update-task -i 1 --append --prompt="Task 1 - Complete:
- All subtasks completed: [list all subtasks]
- Final verification passed: lint, typecheck, CodeRabbit review
- Files changed: [comprehensive list]
- Committed to brief branch"
\`\`\`
#### 4.5.3: Mark Parent Task as Done
\`\`\`bash
tm set-status -i 1 -s done
\`\`\`
**Note**: Do NOT push or create PR yet. Continue to next task in the same branch.
### 4.6: Move to Next Task
\`\`\`bash
# Verify remaining tasks
tm list
# Continue with next task (e.g., Task 2)
# Repeat steps 4.1 through 4.5 for Task 2, then Task 3, etc.
\`\`\`
## Step 5: Complete All Tasks
Continue working through all tasks (Steps 4.1-4.6) until all tasks in the brief are complete. All work is committed to the same \`hamster-brief\` branch.
## Step 6: Final Verification & PR Creation
After ALL tasks are complete:
### 6.1: Verify All Tasks Complete
\`\`\`bash
# Verify all tasks are done
tm list
# Should show all tasks with status 'done'
\`\`\`
### 6.2: Final Quality Checks
\`\`\`bash
# Final comprehensive checks
pnpm lint
pnpm typecheck
coderabbit --prompt-only
# Address any remaining issues if critical
\`\`\`
### 6.3: Push Branch
\`\`\`bash
# Push the brief branch to remote
git push origin hamster-brief
\`\`\`
### 6.4: Create Pull Request to Dev
\`\`\`bash
# Get all task titles (adjust task IDs as needed)
# Create comprehensive PR description
gh pr create \\
--base dev \\
--title "Hamster Brief: Complete Implementation" \\
--body "## Brief Overview
Completed all tasks from Hamster brief.
## Tasks Completed
- [x] Task 1: [Task 1 title]
- Subtasks: 1.1, 1.2, 1.3
- [x] Task 2: [Task 2 title]
- Subtasks: 2.1, 2.2
- [x] Task 3: [Task 3 title]
- [Continue listing all tasks]
## Implementation Summary
- Total tasks: [number]
- Total subtasks: [number]
- Files modified: [comprehensive list]
- All quality checks passed
## Quality Checks
- Linting passed (pnpm lint)
- Type checking passed (pnpm typecheck)
- CodeRabbit review completed for all changes
## Testing
- [ ] Manual testing completed
- [ ] All checks passing
Complete implementation of Hamster brief tasks"
\`\`\`
## Step 7: Cleanup
\`\`\`bash
# After PR is merged, switch back to dev
git checkout dev
git pull origin dev
# Delete local branch (optional)
git branch -d hamster-brief
\`\`\`
## Important Notes
- **Use ONLY**: \`tm list\`, \`tm show <id>\`, \`tm set-status\`, \`tm update-task\`, \`tm auth refresh\`, \`tm context <brief url>\`
- **DON'T use MCP tools** - not compatible with Hamster integration
- **Single branch per brief**: All tasks work in the same branch (\`hamster-brief\`)
- **Single PR per brief**: One PR created after all tasks are complete
- **Always target dev branch** - never main branch
- **Regular logging**: Use \`tm update-task -i <id> --append\` frequently to document:
- Context gathered during exploration
- Implementation decisions made
- Challenges encountered
- Completion status
- **Quality gates**: Never skip lint, typecheck, or CodeRabbit review
- **Commit format**: Follow git_workflow.mdc commit message standards
- **PR format**: Always use \`--base dev\` when creating PRs
## Workflow Summary
\`\`\`
1. Verify connection -> tm context
2. List tasks -> tm list
3. Create single branch -> git checkout -b hamster-brief
4. For each task (in same branch):
a. Read task -> tm show X
b. Log context -> tm update-task -i X --append
c. Mark in-progress -> tm set-status -i X,X.Y -s in-progress
d. For each subtask:
- Read -> tm show X.Y
- Log context -> tm update-task -i X --append
- Implement code
- Verify -> pnpm lint && pnpm typecheck
- Review -> coderabbit --prompt-only
- Log completion -> tm update-task -i X --append
- Commit -> git commit (following git_workflow.mdc format)
- Mark done -> tm set-status -i X.Y -s done
e. Final checks -> pnpm lint && pnpm typecheck && coderabbit --prompt-only
f. Log completion -> tm update-task -i X --append
g. Mark task done -> tm set-status -i X -s done
h. Continue to next task (same branch)
5. After ALL tasks complete:
a. Final verification -> pnpm lint && pnpm typecheck && coderabbit --prompt-only
b. Push branch -> git push origin hamster-brief
c. Create PR -> gh pr create --base dev
\`\`\`
`
});

View File

@@ -0,0 +1,6 @@
/**
* @fileoverview Team Mode Commands
* Commands that only work with API-based storage (Hamster cloud integration).
*/
export { goham } from './goham.js';

View File

@@ -0,0 +1,102 @@
/**
* @fileoverview Factory Functions for Slash Commands
* Simple functions to create type-safe slash command objects.
*/
import type {
StaticSlashCommand,
DynamicSlashCommand,
OperatingMode
} from './types.js';
/**
* Options for creating a static slash command
*/
export interface StaticCommandOptions {
name: string;
description: string;
content: string;
/** Optional argument hint for documentation (command doesn't use $ARGUMENTS) */
argumentHint?: string;
/** Operating mode - defaults to 'common' */
mode?: OperatingMode;
}
/**
* Create a static slash command (no $ARGUMENTS placeholder)
*
* @example
* ```ts
* // Simple static command
* const help = staticCommand({
* name: 'help',
* description: 'Show available commands',
* content: '# Help\n\nList of commands...'
* });
*
* // Static command with optional argument hint
* const goham = staticCommand({
* name: 'goham',
* description: 'Start Working with Hamster Brief',
* argumentHint: '[brief-url]',
* content: '# Start Working...'
* });
* ```
*/
export function staticCommand(
options: StaticCommandOptions
): StaticSlashCommand {
const { name, description, content, argumentHint, mode } = options;
return {
type: 'static',
metadata: {
name,
description,
...(argumentHint && { argumentHint }),
...(mode && { mode })
},
content
};
}
/**
* Create a dynamic slash command that accepts arguments
*
* The content must contain at least one `$ARGUMENTS` placeholder.
*
* @example
* ```ts
* const goham = dynamicCommand(
* 'goham',
* 'Start Working with Hamster Brief',
* '[brief-url]',
* '# Start Working\n\nBrief URL: $ARGUMENTS'
* );
* ```
*
* @throws Error if content doesn't contain $ARGUMENTS placeholder
*/
export function dynamicCommand(
name: string,
description: string,
argumentHint: string,
content: string,
mode?: OperatingMode
): DynamicSlashCommand {
if (!content.includes('$ARGUMENTS')) {
throw new Error(
`Dynamic slash command "${name}" must contain $ARGUMENTS placeholder`
);
}
return {
type: 'dynamic',
metadata: {
name,
description,
argumentHint,
...(mode && { mode })
},
content
};
}

View File

@@ -0,0 +1,43 @@
/**
* @fileoverview Slash Commands Module
* Central exports for the slash command system.
*/
// Types
export type {
SlashCommand,
StaticSlashCommand,
DynamicSlashCommand,
SlashCommandMetadata,
FormattedSlashCommand
} from './types.js';
// Factory functions
export { staticCommand, dynamicCommand } from './factories.js';
export type { StaticCommandOptions } from './factories.js';
// Commands
export { allCommands, goham } from './commands/index.js';
// Profiles - self-contained profile classes for each editor
export {
// Base class
BaseSlashCommandProfile,
// Profile classes (editors that support slash commands)
ClaudeProfile,
CodexProfile,
CursorProfile,
OpenCodeProfile,
RooProfile,
GeminiProfile,
// Utility functions
getProfile,
getAllProfiles,
getProfileNames
} from './profiles/index.js';
// Profile types
export type { SlashCommandResult } from './profiles/index.js';
// Utilities
export { resolveProjectRoot } from './utils.js';

View File

@@ -0,0 +1,376 @@
/**
* @fileoverview Base Slash Command Profile
* Abstract base class for all slash command profiles.
* Follows the same pattern as ai-providers/base-provider.js
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { SlashCommand, FormattedSlashCommand } from '../types.js';
import { filterCommandsByMode } from '../commands/index.js';
/** Default namespace for TaskMaster commands */
export const TM_NAMESPACE = 'tm';
/**
* Result of adding or removing slash commands
*/
export interface SlashCommandResult {
/** Whether the operation was successful */
success: boolean;
/** Number of commands affected */
count: number;
/** Directory where commands were written/removed */
directory: string;
/** List of filenames affected */
files: string[];
/** Error message if operation failed */
error?: string;
}
/**
* Options for adding slash commands
*/
export interface AddSlashCommandsOptions {
/**
* Operating mode to filter commands.
* - 'solo': Solo + common commands (for local file storage)
* - 'team': Team-only commands (exclusive, for Hamster cloud)
* - undefined: All commands (no filtering)
*/
mode?: 'solo' | 'team';
}
/**
* Abstract base class for slash command profiles.
*
* Each profile encapsulates its own formatting logic, directory structure,
* and any profile-specific transformations. This follows SOLID principles:
* - Single Responsibility: Each profile handles only its own formatting
* - Open/Closed: Add new profiles without modifying existing code
* - Liskov Substitution: All profiles are interchangeable via base class
* - Interface Segregation: Base class defines minimal interface
* - Dependency Inversion: Consumers depend on abstraction, not concrete profiles
*
* @example
* ```ts
* import { CursorProfile } from '@tm/profiles';
* import { allCommands } from '@tm/profiles';
*
* const cursor = new CursorProfile();
* cursor.addSlashCommands('/path/to/project', allCommands);
* ```
*/
export abstract class BaseSlashCommandProfile {
/** Profile identifier (lowercase, e.g., 'claude', 'cursor') */
abstract readonly name: string;
/** Display name for UI/logging (e.g., 'Claude Code', 'Cursor') */
abstract readonly displayName: string;
/** Commands directory relative to project root (e.g., '.claude/commands') */
abstract readonly commandsDir: string;
/** File extension for command files (e.g., '.md') */
abstract readonly extension: string;
/**
* Whether this profile supports nested command directories.
* - true: Commands go in a subdirectory (e.g., `.claude/commands/tm/help.md`)
* - false: Commands use a prefix (e.g., `.opencode/command/tm-help.md`)
*
* Override in profiles that don't support nested directories.
*/
readonly supportsNestedCommands: boolean = true;
/**
* Check if this profile supports slash commands.
* Profiles with empty commandsDir do not support commands.
*/
get supportsCommands(): boolean {
return this.commandsDir !== '';
}
/**
* Format a single command for this profile.
* Each profile implements its own formatting logic.
*
* @param command - The slash command to format
* @returns Formatted command ready to write to file
*/
abstract format(command: SlashCommand): FormattedSlashCommand;
/**
* Format all commands for this profile.
*
* @param commands - Array of slash commands to format
* @returns Array of formatted commands
*/
formatAll(commands: SlashCommand[]): FormattedSlashCommand[] {
return commands.map((cmd) => this.format(cmd));
}
/**
* Get the full filename for a command.
* - Nested profiles: `commandName.md` (goes in tm/ subdirectory)
* - Flat profiles: `tm-commandName.md` (uses prefix)
*
* @param commandName - The command name (without extension)
* @returns Full filename with extension
*/
getFilename(commandName: string): string {
if (this.supportsNestedCommands) {
return `${commandName}${this.extension}`;
}
return `${TM_NAMESPACE}-${commandName}${this.extension}`;
}
/**
* Transform the argument placeholder if needed.
* Override in profiles that use different placeholder syntax.
*
* @param content - The command content
* @returns Content with transformed placeholders
*/
transformArgumentPlaceholder(content: string): string {
return content; // Default: no transformation ($ARGUMENTS stays as-is)
}
/**
* Hook for additional post-processing after formatting.
* Override for profile-specific transformations.
*
* @param content - The formatted content
* @returns Post-processed content
*/
postProcess(content: string): string {
return content;
}
/**
* Get the absolute path to the commands directory for a project.
* - Nested profiles: Returns `projectRoot/commandsDir/tm/`
* - Flat profiles: Returns `projectRoot/commandsDir/`
*
* @param projectRoot - Absolute path to the project root
* @returns Absolute path to the commands directory
*/
getCommandsPath(projectRoot: string): string {
if (this.supportsNestedCommands) {
return path.join(projectRoot, this.commandsDir, TM_NAMESPACE);
}
return path.join(projectRoot, this.commandsDir);
}
/**
* Add slash commands to a project.
*
* Formats and writes all provided commands to the profile's commands directory.
* Creates the directory if it doesn't exist.
*
* @param projectRoot - Absolute path to the project root
* @param commands - Array of slash commands to add
* @param options - Options including mode filtering
* @returns Result of the operation
*
* @example
* ```ts
* const cursor = new CursorProfile();
* // Add all commands
* const result = cursor.addSlashCommands('/path/to/project', allCommands);
*
* // Add only solo mode commands
* const soloResult = cursor.addSlashCommands('/path/to/project', allCommands, { mode: 'solo' });
*
* // Add only team mode commands (exclusive)
* const teamResult = cursor.addSlashCommands('/path/to/project', allCommands, { mode: 'team' });
* ```
*/
addSlashCommands(
projectRoot: string,
commands: SlashCommand[],
options?: AddSlashCommandsOptions
): SlashCommandResult {
const commandsPath = this.getCommandsPath(projectRoot);
const files: string[] = [];
if (!this.supportsCommands) {
return {
success: false,
count: 0,
directory: commandsPath,
files: [],
error: `Profile "${this.name}" does not support slash commands`
};
}
try {
// When mode is specified, first remove ALL existing TaskMaster commands
// to ensure clean slate (prevents orphaned commands when switching modes)
if (options?.mode) {
this.removeSlashCommands(projectRoot, commands, false);
}
// Filter commands by mode if specified
const filteredCommands = options?.mode
? filterCommandsByMode(commands, options.mode)
: commands;
// Ensure directory exists
if (!fs.existsSync(commandsPath)) {
fs.mkdirSync(commandsPath, { recursive: true });
}
// Format and write each command
const formatted = this.formatAll(filteredCommands);
for (const output of formatted) {
const filePath = path.join(commandsPath, output.filename);
fs.writeFileSync(filePath, output.content);
files.push(output.filename);
}
return {
success: true,
count: files.length,
directory: commandsPath,
files
};
} catch (err) {
return {
success: false,
count: 0,
directory: commandsPath,
files: [],
error: err instanceof Error ? err.message : String(err)
};
}
}
/**
* Remove slash commands from a project.
*
* Removes only the commands that match the provided command names.
* Preserves user's custom commands that are not in the list.
* Optionally removes the directory if empty after removal.
*
* @param projectRoot - Absolute path to the project root
* @param commands - Array of slash commands to remove (matches by name)
* @param removeEmptyDir - Whether to remove the directory if empty (default: true)
* @returns Result of the operation
*
* @example
* ```ts
* const cursor = new CursorProfile();
* const result = cursor.removeSlashCommands('/path/to/project', allCommands);
* console.log(`Removed ${result.count} commands`);
* ```
*/
removeSlashCommands(
projectRoot: string,
commands: SlashCommand[],
removeEmptyDir: boolean = true
): SlashCommandResult {
const commandsPath = this.getCommandsPath(projectRoot);
const files: string[] = [];
if (!this.supportsCommands) {
return {
success: false,
count: 0,
directory: commandsPath,
files: [],
error: `Profile "${this.name}" does not support slash commands`
};
}
if (!fs.existsSync(commandsPath)) {
return {
success: true,
count: 0,
directory: commandsPath,
files: []
};
}
try {
// Get command names to remove (with appropriate prefix for flat profiles)
const commandNames = new Set(
commands.map((cmd) => {
const name = cmd.metadata.name.toLowerCase();
// For flat profiles, filenames have tm- prefix
return this.supportsNestedCommands ? name : `${TM_NAMESPACE}-${name}`;
})
);
// Get all files in directory
const existingFiles = fs.readdirSync(commandsPath);
for (const file of existingFiles) {
const baseName = path.basename(file, path.extname(file)).toLowerCase();
// Only remove files that match our command names
if (commandNames.has(baseName)) {
const filePath = path.join(commandsPath, file);
fs.rmSync(filePath, { force: true });
files.push(file);
}
}
// Remove directory if empty and requested
if (removeEmptyDir) {
const remainingFiles = fs.readdirSync(commandsPath);
if (remainingFiles.length === 0) {
fs.rmSync(commandsPath, { recursive: true, force: true });
}
}
return {
success: true,
count: files.length,
directory: commandsPath,
files
};
} catch (err) {
return {
success: false,
count: files.length,
directory: commandsPath,
files,
error: err instanceof Error ? err.message : String(err)
};
}
}
/**
* Replace slash commands for a new operating mode.
*
* Removes all existing TaskMaster commands and adds commands for the new mode.
* This is useful when switching between solo and team modes.
*
* @param projectRoot - Absolute path to the project root
* @param commands - Array of all slash commands (will be filtered by mode)
* @param newMode - The new operating mode to switch to
* @returns Result of the operation
*
* @example
* ```ts
* const cursor = new CursorProfile();
* // Switch from solo to team mode
* const result = cursor.replaceSlashCommands('/path/to/project', allCommands, 'team');
* ```
*/
replaceSlashCommands(
projectRoot: string,
commands: SlashCommand[],
newMode: 'solo' | 'team'
): SlashCommandResult {
// Remove all existing TaskMaster commands
const removeResult = this.removeSlashCommands(projectRoot, commands);
if (!removeResult.success) {
return removeResult;
}
// Add commands for the new mode
return this.addSlashCommands(projectRoot, commands, { mode: newMode });
}
}

View File

@@ -0,0 +1,354 @@
import { describe, expect, it } from 'vitest';
import { ClaudeProfile } from './claude-profile.js';
import { staticCommand, dynamicCommand } from '../factories.js';
describe('ClaudeProfile', () => {
describe('Profile metadata', () => {
it('should have correct name', () => {
// Arrange
const profile = new ClaudeProfile();
// Act & Assert
expect(profile.name).toBe('claude');
});
it('should have correct displayName', () => {
// Arrange
const profile = new ClaudeProfile();
// Act & Assert
expect(profile.displayName).toBe('Claude Code');
});
it('should have correct commandsDir', () => {
// Arrange
const profile = new ClaudeProfile();
// Act & Assert
expect(profile.commandsDir).toBe('.claude/commands');
});
it('should have correct extension', () => {
// Arrange
const profile = new ClaudeProfile();
// Act & Assert
expect(profile.extension).toBe('.md');
});
});
describe('supportsCommands', () => {
it('should return true when commandsDir is not empty', () => {
// Arrange
const profile = new ClaudeProfile();
// Act
const result = profile.supportsCommands;
// Assert
expect(result).toBe(true);
});
});
describe('getFilename', () => {
it('should append .md extension to command name', () => {
// Arrange
const profile = new ClaudeProfile();
const commandName = 'goham';
// Act
const result = profile.getFilename(commandName);
// Assert
expect(result).toBe('goham.md');
});
it('should handle command names with hyphens', () => {
// Arrange
const profile = new ClaudeProfile();
const commandName = 'my-command';
// Act
const result = profile.getFilename(commandName);
// Assert
expect(result).toBe('my-command.md');
});
it('should handle single character command names', () => {
// Arrange
const profile = new ClaudeProfile();
const commandName = 'x';
// Act
const result = profile.getFilename(commandName);
// Assert
expect(result).toBe('x.md');
});
});
describe('format() for static commands', () => {
it('should format static command with description on first line', () => {
// Arrange
const profile = new ClaudeProfile();
const command = staticCommand({
name: 'goham',
description: 'Start Working with Hamster Brief',
content: '# Start Working...'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'Start Working with Hamster Brief\n# Start Working...'
);
});
it('should return correct filename for static command', () => {
// Arrange
const profile = new ClaudeProfile();
const command = staticCommand({
name: 'help',
description: 'Show help',
content: '# Help\n\nList of commands...'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('help.md');
});
it('should handle static command with empty content', () => {
// Arrange
const profile = new ClaudeProfile();
const command = staticCommand({
name: 'empty',
description: 'Empty command',
content: ''
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe('Empty command\n');
});
it('should handle static command with multiline content', () => {
// Arrange
const profile = new ClaudeProfile();
const command = staticCommand({
name: 'multi',
description: 'Multiline command',
content: '# Title\n\nParagraph 1\n\nParagraph 2'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'Multiline command\n# Title\n\nParagraph 1\n\nParagraph 2'
);
});
it('should include Arguments line for static command with argumentHint', () => {
// Arrange
const profile = new ClaudeProfile();
const command = staticCommand({
name: 'goham',
description: 'Start Working with Hamster Brief',
argumentHint: '[brief-url]',
content: '# Start Working...'
});
// Act
const result = profile.format(command);
// Assert
// Static commands with argumentHint should include Arguments line
expect(result.content).toBe(
'Start Working with Hamster Brief\n\nArguments: $ARGUMENTS\n# Start Working...'
);
});
});
describe('format() for dynamic commands', () => {
it('should format dynamic command with Arguments line', () => {
// Arrange
const profile = new ClaudeProfile();
const command = dynamicCommand(
'help',
'Help',
'[command]',
'Show help for Task Master AI commands...\n\nCommand: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'Help\n\nArguments: $ARGUMENTS\nShow help for Task Master AI commands...\n\nCommand: $ARGUMENTS'
);
});
it('should return correct filename for dynamic command', () => {
// Arrange
const profile = new ClaudeProfile();
const command = dynamicCommand(
'search',
'Search codebase',
'<query>',
'Search for: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('search.md');
});
it('should preserve $ARGUMENTS placeholder in content', () => {
// Arrange
const profile = new ClaudeProfile();
const command = dynamicCommand(
'run',
'Run command',
'<cmd>',
'Execute: $ARGUMENTS\n\nDone!'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('Execute: $ARGUMENTS');
});
it('should handle dynamic command with multiple $ARGUMENTS placeholders', () => {
// Arrange
const profile = new ClaudeProfile();
const command = dynamicCommand(
'repeat',
'Repeat input',
'<text>',
'First: $ARGUMENTS\nSecond: $ARGUMENTS\nThird: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
const placeholderCount = (result.content.match(/\$ARGUMENTS/g) || [])
.length;
// Header has 1 + content has 3 = 4 total
expect(placeholderCount).toBe(4);
});
it('should include empty line between description and Arguments line', () => {
// Arrange
const profile = new ClaudeProfile();
const command = dynamicCommand(
'test',
'Test description',
'<arg>',
'Content with $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
const lines = result.content.split('\n');
expect(lines[0]).toBe('Test description');
expect(lines[1]).toBe('');
expect(lines[2]).toBe('Arguments: $ARGUMENTS');
});
it('should include empty line between Arguments line and content', () => {
// Arrange
const profile = new ClaudeProfile();
const command = dynamicCommand(
'test',
'Test',
'<arg>',
'Content with $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
const lines = result.content.split('\n');
expect(lines[2]).toBe('Arguments: $ARGUMENTS');
expect(lines[3]).toBe('Content with $ARGUMENTS');
});
});
describe('format() output structure', () => {
it('should return object with filename and content properties', () => {
// Arrange
const profile = new ClaudeProfile();
const command = staticCommand({
name: 'test',
description: 'Test',
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result).toHaveProperty('filename');
expect(result).toHaveProperty('content');
expect(typeof result.filename).toBe('string');
expect(typeof result.content).toBe('string');
});
});
describe('formatAll()', () => {
it('should format multiple commands', () => {
// Arrange
const profile = new ClaudeProfile();
const commands = [
staticCommand({
name: 'cmd1',
description: 'Command 1',
content: 'Content 1'
}),
dynamicCommand(
'cmd2',
'Command 2',
'<arg>',
'Content 2 with $ARGUMENTS'
)
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(2);
expect(results[0].filename).toBe('cmd1.md');
expect(results[1].filename).toBe('cmd2.md');
});
it('should return empty array for empty input', () => {
// Arrange
const profile = new ClaudeProfile();
// Act
const results = profile.formatAll([]);
// Assert
expect(results).toEqual([]);
});
});
});

View File

@@ -0,0 +1,58 @@
/**
* @fileoverview Claude Code Profile
* Slash command profile for Claude Code.
*
* Format:
* ```
* ${description}
*
* Arguments: $ARGUMENTS
*
* [content]
* ```
*
* Location: .claude/commands/*.md
*/
import { BaseSlashCommandProfile } from './base-profile.js';
import type { SlashCommand, FormattedSlashCommand } from '../types.js';
/**
* Claude Code profile for slash commands.
*
* Claude Code uses a simple format with the description as the first line,
* followed by an optional "Arguments: $ARGUMENTS" line for dynamic commands,
* then the main content.
*/
export class ClaudeProfile extends BaseSlashCommandProfile {
readonly name = 'claude';
readonly displayName = 'Claude Code';
readonly commandsDir = '.claude/commands';
readonly extension = '.md';
format(command: SlashCommand): FormattedSlashCommand {
const header = this.buildHeader(command);
const content = this.transformArgumentPlaceholder(command.content);
return {
filename: this.getFilename(command.metadata.name),
content: `${header}${content}`
};
}
/**
* Build the header section for Claude Code format.
* Includes description and optional Arguments line.
*/
private buildHeader(command: SlashCommand): string {
const lines = [command.metadata.description, ''];
// Claude uses "Arguments: $ARGUMENTS" on second line for dynamic commands
if (command.metadata.argumentHint) {
lines.push('Arguments: $ARGUMENTS');
lines.push('');
}
return lines.join('\n');
}
}

View File

@@ -0,0 +1,429 @@
/**
* @fileoverview Unit tests for CodexProfile
* Tests the Codex CLI slash command profile formatting.
*/
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
import { dynamicCommand, staticCommand } from '../factories.js';
import { CodexProfile } from './codex-profile.js';
describe('CodexProfile', () => {
describe('Profile metadata', () => {
it('should have correct profile name', () => {
// Arrange
const profile = new CodexProfile();
// Act & Assert
expect(profile.name).toBe('codex');
});
it('should have correct display name', () => {
// Arrange
const profile = new CodexProfile();
// Act & Assert
expect(profile.displayName).toBe('Codex');
});
it('should have correct commands directory', () => {
// Arrange
const profile = new CodexProfile();
// Act & Assert
expect(profile.commandsDir).toBe('.codex/prompts');
});
it('should have .md file extension', () => {
// Arrange
const profile = new CodexProfile();
// Act & Assert
expect(profile.extension).toBe('.md');
});
});
describe('supportsCommands getter', () => {
it('should return true when commandsDir is set', () => {
// Arrange
const profile = new CodexProfile();
// Act
const result = profile.supportsCommands;
// Assert
expect(result).toBe(true);
});
});
describe('supportsNestedCommands property', () => {
it('should be false for Codex profile (uses tm- prefix)', () => {
// Arrange
const profile = new CodexProfile();
// Act & Assert - Codex uses flat namespace with tm- prefix
expect(profile.supportsNestedCommands).toBe(false);
});
});
describe('getFilename()', () => {
it('should prepend tm- prefix and append .md extension', () => {
// Arrange
const profile = new CodexProfile();
// Act
const filename = profile.getFilename('help');
// Assert - Codex uses flat namespace with tm- prefix
expect(filename).toBe('tm-help.md');
});
it('should handle command names with hyphens', () => {
// Arrange
const profile = new CodexProfile();
// Act
const filename = profile.getFilename('task-status');
// Assert
expect(filename).toBe('tm-task-status.md');
});
it('should handle command names with underscores', () => {
// Arrange
const profile = new CodexProfile();
// Act
const filename = profile.getFilename('get_tasks');
// Assert
expect(filename).toBe('tm-get_tasks.md');
});
});
describe('format() with static commands', () => {
it('should format static command without argumentHint', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help\n\nThis is the help content.'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-help.md');
expect(result.content).toBe(
'---\n' +
'description: "Show available commands"\n' +
'---\n' +
'# Help\n\n' +
'This is the help content.'
);
});
it('should format static command with argumentHint', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'goham',
description: 'Start Working with Hamster Brief',
argumentHint: '[brief-url]',
content: '# Start Working\n\nBegin your task.'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-goham.md');
expect(result.content).toBe(
'---\n' +
'description: "Start Working with Hamster Brief"\n' +
'argument-hint: "[brief-url]"\n' +
'---\n' +
'# Start Working\n\n' +
'Begin your task.'
);
});
it('should include YAML frontmatter delimiter correctly', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'test',
description: 'Test command',
content: 'Content here'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toMatch(/^---\n/);
expect(result.content).toMatch(/\n---\n/);
});
it('should preserve multiline content', () => {
// Arrange
const profile = new CodexProfile();
const multilineContent =
'# Title\n\n## Section 1\n\nParagraph one.\n\n## Section 2\n\nParagraph two.';
const command = staticCommand({
name: 'docs',
description: 'Documentation command',
content: multilineContent
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(multilineContent);
});
});
describe('format() with dynamic commands', () => {
it('should format dynamic command with $ARGUMENTS placeholder', () => {
// Arrange
const profile = new CodexProfile();
const command = dynamicCommand(
'search',
'Search for items',
'<query>',
'Search for: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-search.md');
expect(result.content).toBe(
'---\n' +
'description: "Search for items"\n' +
'argument-hint: "<query>"\n' +
'---\n' +
'Search for: $ARGUMENTS'
);
});
it('should always include argument-hint for dynamic commands', () => {
// Arrange
const profile = new CodexProfile();
const command = dynamicCommand(
'task',
'Manage tasks',
'[task-id]',
'Task ID: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('argument-hint: "[task-id]"');
});
it('should preserve multiple $ARGUMENTS placeholders in content', () => {
// Arrange
const profile = new CodexProfile();
const command = dynamicCommand(
'compare',
'Compare items',
'<id1> <id2>',
'First: $ARGUMENTS\nSecond: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('First: $ARGUMENTS');
expect(result.content).toContain('Second: $ARGUMENTS');
});
});
describe('format() edge cases', () => {
it('should handle description with double quotes', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'quoted',
description: 'Command with "quoted" text',
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(
'description: "Command with \\"quoted\\" text"'
);
});
it('should handle empty content', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'empty',
description: 'Empty content command',
content: ''
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-empty.md');
expect(result.content).toBe(
'---\n' + 'description: "Empty content command"\n' + '---\n'
);
});
it('should handle content that starts with frontmatter-like syntax', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'nested',
description: 'Nested frontmatter test',
content: '---\nsome: yaml\n---\nActual content'
});
// Act
const result = profile.format(command);
// Assert
// The profile should add its own frontmatter, preserving the content as-is
expect(result.content).toBe(
'---\n' +
'description: "Nested frontmatter test"\n' +
'---\n' +
'---\n' +
'some: yaml\n' +
'---\n' +
'Actual content'
);
});
it('should handle special characters in argumentHint', () => {
// Arrange
const profile = new CodexProfile();
const command = staticCommand({
name: 'special',
description: 'Special args',
argumentHint: '<file-path|url> [--flag]',
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(
'argument-hint: "<file-path|url> [--flag]"'
);
});
});
describe('formatAll()', () => {
it('should format multiple commands', () => {
// Arrange
const profile = new CodexProfile();
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: 'Help content'
}),
dynamicCommand('search', 'Search items', '<query>', 'Query: $ARGUMENTS')
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(2);
expect(results[0].filename).toBe('tm-help.md');
expect(results[1].filename).toBe('tm-search.md');
});
it('should return empty array for empty input', () => {
// Arrange
const profile = new CodexProfile();
// Act
const results = profile.formatAll([]);
// Assert
expect(results).toEqual([]);
});
});
describe('isHomeRelative property', () => {
it('should be true indicating home directory usage', () => {
// Arrange
const profile = new CodexProfile();
// Act & Assert
expect(profile.isHomeRelative).toBe(true);
});
});
describe('constructor options', () => {
it('should use os.homedir() by default', () => {
// Arrange
const profile = new CodexProfile();
// Act
const result = profile.getCommandsPath('/any/path');
// Assert
expect(result).toBe(path.join(os.homedir(), '.codex/prompts'));
});
it('should use provided homeDir option when specified', () => {
// Arrange
const customHomeDir = '/custom/home';
const profile = new CodexProfile({ homeDir: customHomeDir });
// Act
const result = profile.getCommandsPath('/any/path');
// Assert
expect(result).toBe('/custom/home/.codex/prompts');
});
});
describe('getCommandsPath()', () => {
it('should return path in user home directory, ignoring projectRoot', () => {
// Arrange
const profile = new CodexProfile();
const projectRoot = '/Users/test/my-project';
// Act
const result = profile.getCommandsPath(projectRoot);
// Assert - Codex uses ~/.codex/prompts, not project-relative
expect(result).toBe(path.join(os.homedir(), '.codex/prompts'));
});
it('should return same path regardless of projectRoot value', () => {
// Arrange
const profile = new CodexProfile();
// Act
const result1 = profile.getCommandsPath('/project/a');
const result2 = profile.getCommandsPath('/project/b');
// Assert - Both should return the same home directory path
expect(result1).toBe(result2);
expect(result1).toBe(path.join(os.homedir(), '.codex/prompts'));
});
});
});

View File

@@ -0,0 +1,103 @@
/**
* @fileoverview Codex Profile
* Slash command profile for OpenAI Codex CLI.
*
* Format:
* ```
* ---
* description: "..."
* argument-hint: "..."
* ---
* [content]
* ```
*
* Location: ~/.codex/prompts/*.md (user's home directory)
*
* Note: Unlike other profiles, Codex stores prompts in the user's home directory,
* not project-relative. This is how Codex CLI discovers custom prompts.
*/
import * as os from 'node:os';
import * as path from 'node:path';
import { BaseSlashCommandProfile } from './base-profile.js';
import type { SlashCommand, FormattedSlashCommand } from '../types.js';
/**
* Options for CodexProfile constructor.
*/
export interface CodexProfileOptions {
/**
* Override the home directory path.
* Used primarily for testing to avoid modifying the real home directory.
* If not provided, uses os.homedir().
*/
homeDir?: string;
}
/**
* Codex CLI profile for slash commands.
*
* Codex uses YAML frontmatter format with description and optional argument-hint.
*/
export class CodexProfile extends BaseSlashCommandProfile {
readonly name = 'codex';
readonly displayName = 'Codex';
readonly commandsDir = '.codex/prompts';
readonly extension = '.md';
readonly supportsNestedCommands = false;
/**
* Whether this profile uses the user's home directory instead of project root.
* Codex CLI reads prompts from ~/.codex/prompts, not project-relative paths.
*/
readonly isHomeRelative = true;
/**
* The home directory to use for command paths.
* Defaults to os.homedir() but can be overridden for testing.
*/
private readonly homeDir: string;
constructor(options?: CodexProfileOptions) {
super();
this.homeDir = options?.homeDir ?? os.homedir();
}
/**
* Override to return home directory path instead of project-relative path.
* Codex CLI reads prompts from ~/.codex/prompts.
*
* @param _projectRoot - Ignored for Codex (uses home directory)
* @returns Absolute path to ~/.codex/prompts
*/
override getCommandsPath(_projectRoot: string): string {
return path.join(this.homeDir, this.commandsDir);
}
format(command: SlashCommand): FormattedSlashCommand {
const frontmatter = this.buildFrontmatter(command);
return {
filename: this.getFilename(command.metadata.name),
content: `${frontmatter}${command.content}`
};
}
private buildFrontmatter(command: SlashCommand): string {
const escapeQuotes = (str: string): string => str.replace(/"/g, '\\"');
const lines = [
'---',
`description: "${escapeQuotes(command.metadata.description)}"`
];
// Include argument-hint if present
if (command.metadata.argumentHint) {
lines.push(
`argument-hint: "${escapeQuotes(command.metadata.argumentHint)}"`
);
}
lines.push('---', '');
return lines.join('\n');
}
}

View File

@@ -0,0 +1,347 @@
/**
* @fileoverview Unit Tests for CursorProfile
* Tests the Cursor slash command profile formatting and metadata.
*/
import { describe, it, expect } from 'vitest';
import { CursorProfile } from './cursor-profile.js';
import { staticCommand, dynamicCommand } from '../factories.js';
describe('CursorProfile', () => {
describe('Profile Metadata', () => {
it('should have correct profile name', () => {
// Arrange
const profile = new CursorProfile();
// Act & Assert
expect(profile.name).toBe('cursor');
});
it('should have correct display name', () => {
// Arrange
const profile = new CursorProfile();
// Act & Assert
expect(profile.displayName).toBe('Cursor');
});
it('should have correct commands directory', () => {
// Arrange
const profile = new CursorProfile();
// Act & Assert
expect(profile.commandsDir).toBe('.cursor/commands');
});
it('should have correct file extension', () => {
// Arrange
const profile = new CursorProfile();
// Act & Assert
expect(profile.extension).toBe('.md');
});
});
describe('supportsCommands getter', () => {
it('should return true when commandsDir is non-empty', () => {
// Arrange
const profile = new CursorProfile();
// Act
const result = profile.supportsCommands;
// Assert
expect(result).toBe(true);
});
});
describe('getFilename() method', () => {
it('should append .md extension to command name', () => {
// Arrange
const profile = new CursorProfile();
// Act
const filename = profile.getFilename('help');
// Assert
expect(filename).toBe('help.md');
});
it('should handle command names with hyphens', () => {
// Arrange
const profile = new CursorProfile();
// Act
const filename = profile.getFilename('my-command');
// Assert
expect(filename).toBe('my-command.md');
});
it('should handle command names with underscores', () => {
// Arrange
const profile = new CursorProfile();
// Act
const filename = profile.getFilename('my_command');
// Assert
expect(filename).toBe('my_command.md');
});
});
describe('format() method for static commands', () => {
it('should return content unchanged for simple static command', () => {
// Arrange
const profile = new CursorProfile();
const command = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help\n\nList of available commands...'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('help.md');
expect(result.content).toBe('# Help\n\nList of available commands...');
});
it('should preserve multiline content exactly', () => {
// Arrange
const profile = new CursorProfile();
const multilineContent = `# Task Runner
## Description
Run automated tasks for the project.
## Steps
1. Check dependencies
2. Run build
3. Execute tests
4. Generate report`;
const command = staticCommand({
name: 'task-runner',
description: 'Run automated tasks',
content: multilineContent
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('task-runner.md');
expect(result.content).toBe(multilineContent);
});
it('should preserve static command with argumentHint', () => {
// Arrange
const profile = new CursorProfile();
const command = staticCommand({
name: 'analyze',
description: 'Analyze codebase',
argumentHint: '[path]',
content: '# Analyze\n\nAnalyze the specified path.'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('analyze.md');
expect(result.content).toBe('# Analyze\n\nAnalyze the specified path.');
});
it('should preserve code blocks in content', () => {
// Arrange
const profile = new CursorProfile();
const contentWithCode = `# Deploy
Run the deployment:
\`\`\`bash
npm run deploy
\`\`\`
Done!`;
const command = staticCommand({
name: 'deploy',
description: 'Deploy the application',
content: contentWithCode
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(contentWithCode);
});
it('should preserve special characters in content', () => {
// Arrange
const profile = new CursorProfile();
const contentWithSpecialChars =
'# Special\n\nUse `$HOME` and `$PATH` variables. Also: <tag> & "quotes"';
const command = staticCommand({
name: 'special',
description: 'Command with special chars',
content: contentWithSpecialChars
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(contentWithSpecialChars);
});
});
describe('format() method for dynamic commands', () => {
it('should preserve $ARGUMENTS placeholder unchanged', () => {
// Arrange
const profile = new CursorProfile();
const command = dynamicCommand(
'review',
'Review a pull request',
'<pr-number>',
'# Review PR\n\nReviewing PR: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('review.md');
expect(result.content).toBe('# Review PR\n\nReviewing PR: $ARGUMENTS');
expect(result.content).toContain('$ARGUMENTS');
});
it('should preserve multiple $ARGUMENTS placeholders', () => {
// Arrange
const profile = new CursorProfile();
const command = dynamicCommand(
'compare',
'Compare two items',
'<item1> <item2>',
'First: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'First: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
expect(result.content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
it('should preserve $ARGUMENTS in complex markdown content', () => {
// Arrange
const profile = new CursorProfile();
const complexContent = `# Search Command
## Input
User provided: $ARGUMENTS
## Steps
1. Parse the input: \`$ARGUMENTS\`
2. Search for matches
3. Display results
\`\`\`
Query: $ARGUMENTS
\`\`\``;
const command = dynamicCommand(
'search',
'Search the codebase',
'<query>',
complexContent
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(complexContent);
expect(result.content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
});
describe('formatAll() method', () => {
it('should format multiple commands correctly', () => {
// Arrange
const profile = new CursorProfile();
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help Content'
}),
dynamicCommand('run', 'Run a command', '<cmd>', 'Running: $ARGUMENTS')
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(2);
expect(results[0].filename).toBe('help.md');
expect(results[0].content).toBe('# Help Content');
expect(results[1].filename).toBe('run.md');
expect(results[1].content).toBe('Running: $ARGUMENTS');
});
it('should return empty array for empty input', () => {
// Arrange
const profile = new CursorProfile();
// Act
const results = profile.formatAll([]);
// Assert
expect(results).toEqual([]);
});
});
describe('getCommandsPath() method', () => {
it('should return correct absolute path for commands directory with tm subdirectory', () => {
// Arrange
const profile = new CursorProfile();
const projectRoot = '/home/user/my-project';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert - Cursor supports nested commands, so path includes tm/ subdirectory
expect(commandsPath).toBe('/home/user/my-project/.cursor/commands/tm');
});
it('should handle project root with trailing slash', () => {
// Arrange
const profile = new CursorProfile();
const projectRoot = '/home/user/my-project/';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert - path.join normalizes the path, includes tm/ subdirectory
expect(commandsPath).toBe('/home/user/my-project/.cursor/commands/tm');
});
});
describe('supportsNestedCommands property', () => {
it('should be true for Cursor profile', () => {
// Arrange
const profile = new CursorProfile();
// Assert - Cursor supports nested command directories
expect(profile.supportsNestedCommands).toBe(true);
});
});
});

View File

@@ -0,0 +1,37 @@
/**
* @fileoverview Cursor Profile
* Slash command profile for Cursor.
*
* Format:
* ```
* [content as-is]
* ```
*
* Cursor uses plain markdown format with no header or transformation.
*
* Location: .cursor/commands/*.md
*/
import { BaseSlashCommandProfile } from './base-profile.js';
import type { SlashCommand, FormattedSlashCommand } from '../types.js';
/**
* Cursor profile for slash commands.
*
* Cursor uses plain markdown format - commands are written as-is
* without any header or transformation. The content is simply
* passed through directly.
*/
export class CursorProfile extends BaseSlashCommandProfile {
readonly name = 'cursor';
readonly displayName = 'Cursor';
readonly commandsDir = '.cursor/commands';
readonly extension = '.md';
format(command: SlashCommand): FormattedSlashCommand {
return {
filename: this.getFilename(command.metadata.name),
content: command.content
};
}
}

View File

@@ -0,0 +1,675 @@
/**
* @fileoverview Unit Tests for GeminiProfile
* Tests the Gemini CLI slash command profile formatting and metadata.
*/
import { describe, expect, it } from 'vitest';
import { dynamicCommand, staticCommand } from '../factories.js';
import { GeminiProfile } from './gemini-profile.js';
describe('GeminiProfile', () => {
describe('Profile Metadata', () => {
it('should have correct profile name', () => {
// Arrange
const profile = new GeminiProfile();
// Act & Assert
expect(profile.name).toBe('gemini');
});
it('should have correct display name', () => {
// Arrange
const profile = new GeminiProfile();
// Act & Assert
expect(profile.displayName).toBe('Gemini');
});
it('should have correct commands directory', () => {
// Arrange
const profile = new GeminiProfile();
// Act & Assert
expect(profile.commandsDir).toBe('.gemini/commands');
});
it('should have correct file extension', () => {
// Arrange
const profile = new GeminiProfile();
// Act & Assert
expect(profile.extension).toBe('.toml');
});
});
describe('supportsCommands getter', () => {
it('should return true when commandsDir is non-empty', () => {
// Arrange
const profile = new GeminiProfile();
// Act
const result = profile.supportsCommands;
// Assert
expect(result).toBe(true);
});
});
describe('getFilename() method', () => {
it('should append .toml extension to command name', () => {
// Arrange
const profile = new GeminiProfile();
// Act
const filename = profile.getFilename('help');
// Assert
expect(filename).toBe('help.toml');
});
it('should handle command names with hyphens', () => {
// Arrange
const profile = new GeminiProfile();
// Act
const filename = profile.getFilename('my-command');
// Assert
expect(filename).toBe('my-command.toml');
});
it('should handle command names with underscores', () => {
// Arrange
const profile = new GeminiProfile();
// Act
const filename = profile.getFilename('my_command');
// Assert
expect(filename).toBe('my_command.toml');
});
it('should handle single character command names', () => {
// Arrange
const profile = new GeminiProfile();
// Act
const filename = profile.getFilename('x');
// Assert
expect(filename).toBe('x.toml');
});
});
describe('format() method for static commands', () => {
it('should format simple static command with description and prompt', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help\n\nList of available commands...'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('help.toml');
expect(result.content).toBe(
'description="Show available commands"\nprompt = """\n# Help\n\nList of available commands...\n"""\n'
);
});
it('should trim content inside prompt block', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Test command',
content: ' \n# Test Content\n\n '
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'description="Test command"\nprompt = """\n# Test Content\n"""\n'
);
});
it('should preserve multiline content in prompt block', () => {
// Arrange
const profile = new GeminiProfile();
const multilineContent = `# Task Runner
## Description
Run automated tasks for the project.
## Steps
1. Check dependencies
2. Run build
3. Execute tests
4. Generate report`;
const command = staticCommand({
name: 'task-runner',
description: 'Run automated tasks',
content: multilineContent
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('task-runner.toml');
expect(result.content).toBe(
`description="Run automated tasks"
prompt = """
${multilineContent}
"""
`
);
});
it('should escape double quotes in description', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Test "quoted" description',
content: '# Test Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'description="Test \\"quoted\\" description"\nprompt = """\n# Test Content\n"""\n'
);
});
it('should escape multiple double quotes in description', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Use "this" and "that" and "other"',
content: '# Test'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(
'description="Use \\"this\\" and \\"that\\" and \\"other\\"'
);
});
it('should preserve static command with argumentHint', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'analyze',
description: 'Analyze codebase',
argumentHint: '[path]',
content: '# Analyze\n\nAnalyze the specified path.'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('analyze.toml');
expect(result.content).toBe(
'description="Analyze codebase"\nprompt = """\n# Analyze\n\nAnalyze the specified path.\n"""\n'
);
});
it('should preserve code blocks in content', () => {
// Arrange
const profile = new GeminiProfile();
const contentWithCode = `# Deploy
Run the deployment:
\`\`\`bash
npm run deploy
\`\`\`
Done!`;
const command = staticCommand({
name: 'deploy',
description: 'Deploy the application',
content: contentWithCode
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('```bash');
expect(result.content).toContain('npm run deploy');
expect(result.content).toContain('```');
});
it('should preserve special characters in content', () => {
// Arrange
const profile = new GeminiProfile();
const contentWithSpecialChars =
'# Special\n\nUse `$HOME` and `$PATH` variables. Also: <tag> & "quotes"';
const command = staticCommand({
name: 'special',
description: 'Command with special chars',
content: contentWithSpecialChars
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('$HOME');
expect(result.content).toContain('$PATH');
expect(result.content).toContain('<tag>');
expect(result.content).toContain('&');
expect(result.content).toContain('"quotes"');
});
it('should handle empty content', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'empty',
description: 'Empty command',
content: ''
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'description="Empty command"\nprompt = """\n\n"""\n'
);
});
});
describe('format() method for dynamic commands', () => {
it('should format dynamic command with $ARGUMENTS placeholder', () => {
// Arrange
const profile = new GeminiProfile();
const command = dynamicCommand(
'review',
'Review a pull request',
'<pr-number>',
'# Review PR\n\nReviewing PR: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('review.toml');
expect(result.content).toBe(
'description="Review a pull request"\nprompt = """\n# Review PR\n\nReviewing PR: $ARGUMENTS\n"""\n'
);
expect(result.content).toContain('$ARGUMENTS');
});
it('should preserve multiple $ARGUMENTS placeholders', () => {
// Arrange
const profile = new GeminiProfile();
const command = dynamicCommand(
'compare',
'Compare two items',
'<item1> <item2>',
'First: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
const placeholderCount = (result.content.match(/\$ARGUMENTS/g) || [])
.length;
expect(placeholderCount).toBe(3);
expect(result.content).toContain('First: $ARGUMENTS');
expect(result.content).toContain('Second: $ARGUMENTS');
expect(result.content).toContain('Both: $ARGUMENTS');
});
it('should preserve $ARGUMENTS in complex markdown content', () => {
// Arrange
const profile = new GeminiProfile();
const complexContent = `# Search Command
## Input
User provided: $ARGUMENTS
## Steps
1. Parse the input: \`$ARGUMENTS\`
2. Search for matches
3. Display results
\`\`\`
Query: $ARGUMENTS
\`\`\``;
const command = dynamicCommand(
'search',
'Search the codebase',
'<query>',
complexContent
);
// Act
const result = profile.format(command);
// Assert
const placeholderCount = (result.content.match(/\$ARGUMENTS/g) || [])
.length;
expect(placeholderCount).toBe(3);
expect(result.content).toContain('User provided: $ARGUMENTS');
expect(result.content).toContain('Parse the input: `$ARGUMENTS`');
expect(result.content).toContain('Query: $ARGUMENTS');
});
it('should escape quotes in dynamic command description', () => {
// Arrange
const profile = new GeminiProfile();
const command = dynamicCommand(
'run',
'Run "command" with args',
'<cmd>',
'Running: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(
'description="Run \\"command\\" with args"'
);
});
it('should trim content in dynamic commands', () => {
// Arrange
const profile = new GeminiProfile();
const command = dynamicCommand(
'test',
'Test command',
'<arg>',
' \nContent: $ARGUMENTS\n '
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'description="Test command"\nprompt = """\nContent: $ARGUMENTS\n"""\n'
);
});
});
describe('format() output structure', () => {
it('should return object with filename and content properties', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Test',
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result).toHaveProperty('filename');
expect(result).toHaveProperty('content');
expect(typeof result.filename).toBe('string');
expect(typeof result.content).toBe('string');
});
it('should have consistent format structure across different commands', () => {
// Arrange
const profile = new GeminiProfile();
const commands = [
staticCommand({
name: 'cmd1',
description: 'Command 1',
content: 'Content 1'
}),
dynamicCommand('cmd2', 'Command 2', '<arg>', 'Content 2 $ARGUMENTS')
];
// Act
const results = commands.map((cmd) => profile.format(cmd));
// Assert
results.forEach((result) => {
expect(result.content).toMatch(
/^description=".*"\nprompt = """\n[\s\S]*\n"""\n$/
);
});
});
});
describe('formatAll() method', () => {
it('should format multiple commands correctly', () => {
// Arrange
const profile = new GeminiProfile();
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help Content'
}),
dynamicCommand('run', 'Run a command', '<cmd>', 'Running: $ARGUMENTS')
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(2);
expect(results[0].filename).toBe('help.toml');
expect(results[0].content).toContain('description="Show help"');
expect(results[1].filename).toBe('run.toml');
expect(results[1].content).toContain('description="Run a command"');
expect(results[1].content).toContain('$ARGUMENTS');
});
it('should return empty array for empty input', () => {
// Arrange
const profile = new GeminiProfile();
// Act
const results = profile.formatAll([]);
// Assert
expect(results).toEqual([]);
});
it('should handle mixed static and dynamic commands', () => {
// Arrange
const profile = new GeminiProfile();
const commands = [
staticCommand({
name: 'static1',
description: 'Static command 1',
content: 'Content 1'
}),
dynamicCommand('dynamic1', 'Dynamic command 1', '<arg>', '$ARGUMENTS'),
staticCommand({
name: 'static2',
description: 'Static command 2',
content: 'Content 2'
})
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(3);
expect(results[0].filename).toBe('static1.toml');
expect(results[1].filename).toBe('dynamic1.toml');
expect(results[2].filename).toBe('static2.toml');
});
});
describe('getCommandsPath() method', () => {
it('should return correct absolute path for commands directory with tm subdirectory', () => {
// Arrange
const profile = new GeminiProfile();
const projectRoot = '/home/user/my-project';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
expect(commandsPath).toBe('/home/user/my-project/.gemini/commands/tm');
});
it('should handle project root with trailing slash', () => {
// Arrange
const profile = new GeminiProfile();
const projectRoot = '/home/user/my-project/';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
// path.join normalizes the path
expect(commandsPath).toBe('/home/user/my-project/.gemini/commands/tm');
});
it('should handle Windows-style paths', () => {
// Arrange
const profile = new GeminiProfile();
const projectRoot = 'C:\\Users\\user\\my-project';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
expect(commandsPath).toContain('.gemini');
expect(commandsPath).toContain('commands');
});
});
describe('escapeForTripleQuotedString() edge cases', () => {
it('should escape triple quotes in content to prevent TOML delimiter break', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Test command',
content: 'Content with """ triple quotes'
});
// Act
const result = profile.format(command);
// Assert
// The triple quotes should be escaped so they don't break the TOML delimiter
expect(result.content).not.toContain('Content with """ triple quotes');
expect(result.content).toContain('Content with ""\\" triple quotes');
});
it('should escape multiple triple quote sequences in content', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Test command',
content: 'First """ and second """ here'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('First ""\\" and second ""\\" here');
});
it('should handle content that is just triple quotes', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Test command',
content: '"""'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('prompt = """\n""\\"\n"""');
});
});
describe('escapeForPython() edge cases', () => {
it('should handle description with only quotes', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: '"""',
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain('description="\\"\\"\\""');
});
it('should handle description with mixed quotes and text', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: 'Start "working" on "task" now',
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(
'description="Start \\"working\\" on \\"task\\" now"'
);
});
it('should not escape single quotes in description', () => {
// Arrange
const profile = new GeminiProfile();
const command = staticCommand({
name: 'test',
description: "It's a test with 'single quotes'",
content: 'Content'
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toContain(
"description=\"It's a test with 'single quotes'\""
);
expect(result.content).not.toContain("\\'");
});
});
});

View File

@@ -0,0 +1,59 @@
/**
* @fileoverview Gemini CLI Profile
* Slash command profile for Google Gemini CLI.
*
* Format:
* ```
* description="..."
* prompt = """
* [content]
* """
* ```
*
* Location: .gemini/commands/*.toml
*/
import { BaseSlashCommandProfile } from './base-profile.js';
import type { SlashCommand, FormattedSlashCommand } from '../types.js';
/**
* Gemini CLI profile for slash commands.
*
* Gemini uses a Python-style format with description and prompt fields.
* The prompt content is wrapped in triple quotes.
*/
export class GeminiProfile extends BaseSlashCommandProfile {
readonly name = 'gemini';
readonly displayName = 'Gemini';
readonly commandsDir = '.gemini/commands';
readonly extension = '.toml';
format(command: SlashCommand): FormattedSlashCommand {
const description = this.escapeForPython(command.metadata.description);
const content = this.escapeForTripleQuotedString(command.content.trim());
return {
filename: this.getFilename(command.metadata.name),
content: `description="${description}"
prompt = """
${content}
"""
`
};
}
/**
* Escape double quotes for Python string literals.
*/
private escapeForPython(str: string): string {
return str.replace(/"/g, '\\"');
}
/**
* Escape content for use inside triple-quoted strings.
* Prevents `"""` sequences from breaking the TOML delimiter.
*/
private escapeForTripleQuotedString(str: string): string {
return str.replace(/"""/g, '""\\"');
}
}

View File

@@ -0,0 +1,458 @@
/**
* @fileoverview Unit tests for profile utility functions
*
* Tests the profile lookup and management functions exported from index.ts:
* - getProfile(name) - returns profile by name (case-insensitive)
* - getAllProfiles() - returns array of all profile instances
* - getProfileNames() - returns array of profile names
*/
import { describe, expect, it } from 'vitest';
import {
BaseSlashCommandProfile,
ClaudeProfile,
CodexProfile,
CursorProfile,
GeminiProfile,
OpenCodeProfile,
RooProfile,
getAllProfiles,
getProfile,
getProfileNames
} from './index.js';
describe('Profile Utility Functions', () => {
describe('getProfile', () => {
describe('returns correct profile for valid names', () => {
it('returns ClaudeProfile for "claude"', () => {
// Arrange
const name = 'claude';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(ClaudeProfile);
expect(profile?.name).toBe('claude');
expect(profile?.displayName).toBe('Claude Code');
});
it('returns CursorProfile for "cursor"', () => {
// Arrange
const name = 'cursor';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(CursorProfile);
expect(profile?.name).toBe('cursor');
expect(profile?.displayName).toBe('Cursor');
});
it('returns RooProfile for "roo"', () => {
// Arrange
const name = 'roo';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(RooProfile);
expect(profile?.name).toBe('roo');
expect(profile?.displayName).toBe('Roo Code');
});
it('returns GeminiProfile for "gemini"', () => {
// Arrange
const name = 'gemini';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(GeminiProfile);
expect(profile?.name).toBe('gemini');
expect(profile?.displayName).toBe('Gemini');
});
it('returns CodexProfile for "codex"', () => {
// Arrange
const name = 'codex';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(CodexProfile);
expect(profile?.name).toBe('codex');
expect(profile?.displayName).toBe('Codex');
});
});
describe('case insensitive lookup', () => {
it('returns ClaudeProfile for "CLAUDE" (uppercase)', () => {
// Arrange
const name = 'CLAUDE';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(ClaudeProfile);
expect(profile?.name).toBe('claude');
});
it('returns ClaudeProfile for "Claude" (title case)', () => {
// Arrange
const name = 'Claude';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(ClaudeProfile);
expect(profile?.name).toBe('claude');
});
it('returns ClaudeProfile for "cLaUdE" (mixed case)', () => {
// Arrange
const name = 'cLaUdE';
// Act
const profile = getProfile(name);
// Assert
expect(profile).toBeInstanceOf(ClaudeProfile);
expect(profile?.name).toBe('claude');
});
it('handles case insensitivity for other profiles', () => {
// Act & Assert
expect(getProfile('CURSOR')).toBeInstanceOf(CursorProfile);
expect(getProfile('Roo')).toBeInstanceOf(RooProfile);
expect(getProfile('GEMINI')).toBeInstanceOf(GeminiProfile);
expect(getProfile('CODEX')).toBeInstanceOf(CodexProfile);
expect(getProfile('OPENCODE')).toBeInstanceOf(OpenCodeProfile);
});
});
describe('unknown profile handling', () => {
it('returns undefined for unknown profile name', () => {
// Arrange
const unknownName = 'unknown-profile';
// Act
const profile = getProfile(unknownName);
// Assert
expect(profile).toBeUndefined();
});
it('returns undefined for empty string', () => {
// Arrange
const emptyName = '';
// Act
const profile = getProfile(emptyName);
// Assert
expect(profile).toBeUndefined();
});
it('returns undefined for profile with typo', () => {
// Arrange
const typoName = 'cusor'; // missing 'r'
// Act
const profile = getProfile(typoName);
// Assert
expect(profile).toBeUndefined();
});
});
});
describe('getAllProfiles', () => {
it('returns an array', () => {
// Act
const profiles = getAllProfiles();
// Assert
expect(Array.isArray(profiles)).toBe(true);
});
it('contains 6 profiles', () => {
// Act
const profiles = getAllProfiles();
// Assert
expect(profiles).toHaveLength(6);
});
it('each profile is a BaseSlashCommandProfile instance', () => {
// Act
const profiles = getAllProfiles();
// Assert
for (const profile of profiles) {
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
}
});
it('contains all expected profile types', () => {
// Act
const profiles = getAllProfiles();
// Assert
const profileTypes = profiles.map((p) => p.constructor.name);
expect(profileTypes).toContain('ClaudeProfile');
expect(profileTypes).toContain('CursorProfile');
expect(profileTypes).toContain('RooProfile');
expect(profileTypes).toContain('GeminiProfile');
expect(profileTypes).toContain('CodexProfile');
expect(profileTypes).toContain('OpenCodeProfile');
});
it('returns new array reference on each call (defensive copy)', () => {
// Act
const profiles1 = getAllProfiles();
const profiles2 = getAllProfiles();
// Assert - arrays should be different references
expect(profiles1).not.toBe(profiles2);
// But contain the same profile instances (singleton pattern)
expect(profiles1).toEqual(profiles2);
});
it('each profile has required properties', () => {
// Act
const profiles = getAllProfiles();
// Assert
for (const profile of profiles) {
expect(profile.name).toBeDefined();
expect(typeof profile.name).toBe('string');
expect(profile.displayName).toBeDefined();
expect(typeof profile.displayName).toBe('string');
expect(profile.commandsDir).toBeDefined();
expect(typeof profile.commandsDir).toBe('string');
expect(profile.extension).toBeDefined();
expect(typeof profile.extension).toBe('string');
}
});
it('each profile has supportsCommands === true', () => {
// Act
const profiles = getAllProfiles();
// Assert
for (const profile of profiles) {
expect(profile.supportsCommands).toBe(true);
}
});
});
describe('getProfileNames', () => {
it('returns an array of strings', () => {
// Act
const names = getProfileNames();
// Assert
expect(Array.isArray(names)).toBe(true);
for (const name of names) {
expect(typeof name).toBe('string');
}
});
it('contains "claude"', () => {
// Act
const names = getProfileNames();
// Assert
expect(names).toContain('claude');
});
it('contains "cursor"', () => {
// Act
const names = getProfileNames();
// Assert
expect(names).toContain('cursor');
});
it('contains "roo"', () => {
// Act
const names = getProfileNames();
// Assert
expect(names).toContain('roo');
});
it('contains "gemini"', () => {
// Act
const names = getProfileNames();
// Assert
expect(names).toContain('gemini');
});
it('contains "codex"', () => {
// Act
const names = getProfileNames();
// Assert
expect(names).toContain('codex');
});
it('returns all 6 profile names', () => {
// Act
const names = getProfileNames();
// Assert
expect(names).toHaveLength(6);
expect(names).toEqual(
expect.arrayContaining([
'claude',
'cursor',
'roo',
'gemini',
'codex',
'opencode'
])
);
});
it('all names are lowercase', () => {
// Act
const names = getProfileNames();
// Assert
for (const name of names) {
expect(name).toBe(name.toLowerCase());
}
});
it('names match getProfile lookup keys', () => {
// Act
const names = getProfileNames();
// Assert - each name should return a valid profile
for (const name of names) {
const profile = getProfile(name);
expect(profile).toBeDefined();
expect(profile?.name).toBe(name);
}
});
});
describe('profile singleton consistency', () => {
it('getProfile returns same instance for repeated calls', () => {
// Act
const profile1 = getProfile('claude');
const profile2 = getProfile('claude');
// Assert - should be same singleton instance
expect(profile1).toBe(profile2);
});
it('getAllProfiles contains same instances as getProfile', () => {
// Act
const allProfiles = getAllProfiles();
const claudeFromGet = getProfile('claude');
const claudeFromAll = allProfiles.find((p) => p.name === 'claude');
// Assert
expect(claudeFromGet).toBe(claudeFromAll);
});
});
describe('Profile class instantiation', () => {
it('can instantiate ClaudeProfile', () => {
// Act
const profile = new ClaudeProfile();
// Assert
expect(profile).toBeInstanceOf(ClaudeProfile);
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
expect(profile.name).toBe('claude');
expect(profile.displayName).toBe('Claude Code');
expect(profile.commandsDir).toBe('.claude/commands');
expect(profile.extension).toBe('.md');
expect(profile.supportsCommands).toBe(true);
});
it('can instantiate CodexProfile', () => {
// Act
const profile = new CodexProfile();
// Assert
expect(profile).toBeInstanceOf(CodexProfile);
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
expect(profile.name).toBe('codex');
expect(profile.displayName).toBe('Codex');
expect(profile.commandsDir).toBe('.codex/prompts');
expect(profile.extension).toBe('.md');
expect(profile.supportsCommands).toBe(true);
});
it('can instantiate CursorProfile', () => {
// Act
const profile = new CursorProfile();
// Assert
expect(profile).toBeInstanceOf(CursorProfile);
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
expect(profile.name).toBe('cursor');
expect(profile.displayName).toBe('Cursor');
expect(profile.commandsDir).toBe('.cursor/commands');
expect(profile.extension).toBe('.md');
expect(profile.supportsCommands).toBe(true);
});
it('can instantiate RooProfile', () => {
// Act
const profile = new RooProfile();
// Assert
expect(profile).toBeInstanceOf(RooProfile);
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
expect(profile.name).toBe('roo');
expect(profile.displayName).toBe('Roo Code');
expect(profile.commandsDir).toBe('.roo/commands');
expect(profile.extension).toBe('.md');
expect(profile.supportsCommands).toBe(true);
});
it('can instantiate GeminiProfile', () => {
// Act
const profile = new GeminiProfile();
// Assert
expect(profile).toBeInstanceOf(GeminiProfile);
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
expect(profile.name).toBe('gemini');
expect(profile.displayName).toBe('Gemini');
expect(profile.commandsDir).toBe('.gemini/commands');
expect(profile.extension).toBe('.toml');
expect(profile.supportsCommands).toBe(true);
});
it('all instantiated profiles extend BaseSlashCommandProfile', () => {
// Act
const profiles = [
new ClaudeProfile(),
new CodexProfile(),
new CursorProfile(),
new RooProfile(),
new GeminiProfile(),
new OpenCodeProfile()
];
// Assert
for (const profile of profiles) {
expect(profile).toBeInstanceOf(BaseSlashCommandProfile);
}
});
});
});

View File

@@ -0,0 +1,97 @@
/**
* Slash Command Profiles Index
*
* This module exports all slash command profile classes and provides
* utility functions for profile lookup and management.
*
* Supported profiles (with slash commands):
* - Claude Code: .claude/commands
* - Cursor: .cursor/commands
* - Roo Code: .roo/commands
* - Gemini: .gemini/commands
* - Codex: .codex/prompts
* - OpenCode: .opencode/command
*/
// Base profile class and types
import { BaseSlashCommandProfile } from './base-profile.js';
export type { SlashCommandResult } from './base-profile.js';
// Individual profile classes
import { ClaudeProfile } from './claude-profile.js';
import { CodexProfile } from './codex-profile.js';
import { CursorProfile } from './cursor-profile.js';
import { GeminiProfile } from './gemini-profile.js';
import { OpenCodeProfile } from './opencode-profile.js';
import { RooProfile } from './roo-profile.js';
// Re-export base class and all profile classes for direct use
export { BaseSlashCommandProfile };
export { ClaudeProfile };
export { CodexProfile };
export type { CodexProfileOptions } from './codex-profile.js';
export { CursorProfile };
export { GeminiProfile };
export { OpenCodeProfile };
export { RooProfile };
/**
* Singleton instances of all available slash command profiles.
* Keys are lowercase profile names for case-insensitive lookup.
*/
const profiles: Record<string, BaseSlashCommandProfile> = {
claude: new ClaudeProfile(),
codex: new CodexProfile(),
cursor: new CursorProfile(),
gemini: new GeminiProfile(),
opencode: new OpenCodeProfile(),
roo: new RooProfile()
};
/**
* Get a slash command profile by name.
*
* @param name - The profile name (case-insensitive)
* @returns The profile instance if found, undefined otherwise
*
* @example
* ```typescript
* const claudeProfile = getProfile('claude');
* const cursorProfile = getProfile('CURSOR'); // Case-insensitive
* ```
*/
export function getProfile(name: string): BaseSlashCommandProfile | undefined {
return profiles[name.toLowerCase()];
}
/**
* Get all available slash command profiles.
*
* @returns Array of all profile instances
*
* @example
* ```typescript
* const allProfiles = getAllProfiles();
* allProfiles.forEach(profile => {
* console.log(profile.name, profile.commandsDir);
* });
* ```
*/
export function getAllProfiles(): BaseSlashCommandProfile[] {
return Object.values(profiles);
}
/**
* Get all available profile names.
*
* @returns Array of profile names (lowercase)
*
* @example
* ```typescript
* const names = getProfileNames();
* // ['claude', 'cursor', 'roo', 'gemini']
* ```
*/
export function getProfileNames(): string[] {
return Object.keys(profiles);
}

View File

@@ -0,0 +1,347 @@
/**
* @fileoverview Unit Tests for OpenCodeProfile
* Tests the OpenCode slash command profile formatting and metadata.
*/
import { describe, it, expect } from 'vitest';
import { OpenCodeProfile } from './opencode-profile.js';
import { staticCommand, dynamicCommand } from '../factories.js';
describe('OpenCodeProfile', () => {
describe('Profile Metadata', () => {
it('should have correct profile name', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act & Assert
expect(profile.name).toBe('opencode');
});
it('should have correct display name', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act & Assert
expect(profile.displayName).toBe('OpenCode');
});
it('should have correct commands directory', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act & Assert
expect(profile.commandsDir).toBe('.opencode/command');
});
it('should have correct file extension', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act & Assert
expect(profile.extension).toBe('.md');
});
});
describe('supportsCommands getter', () => {
it('should return true when commandsDir is non-empty', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act
const result = profile.supportsCommands;
// Assert
expect(result).toBe(true);
});
});
describe('supportsNestedCommands property', () => {
it('should be false (uses tm- prefix instead of subdirectory)', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act & Assert
expect(profile.supportsNestedCommands).toBe(false);
});
});
describe('getFilename() method', () => {
it('should add tm- prefix and .md extension to command name', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act
const filename = profile.getFilename('help');
// Assert
expect(filename).toBe('tm-help.md');
});
it('should handle command names with hyphens', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act
const filename = profile.getFilename('my-command');
// Assert
expect(filename).toBe('tm-my-command.md');
});
it('should handle command names with underscores', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act
const filename = profile.getFilename('my_command');
// Assert
expect(filename).toBe('tm-my_command.md');
});
});
describe('format() method for static commands', () => {
it('should add frontmatter with description for simple static command', () => {
// Arrange
const profile = new OpenCodeProfile();
const command = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help\n\nList of available commands...'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-help.md');
expect(result.content).toBe(
'---\ndescription: Show available commands\n---\n# Help\n\nList of available commands...'
);
});
it('should preserve multiline content with frontmatter', () => {
// Arrange
const profile = new OpenCodeProfile();
const multilineContent = `# Task Runner
## Description
Run automated tasks for the project.
## Steps
1. Check dependencies
2. Run build
3. Execute tests
4. Generate report`;
const command = staticCommand({
name: 'task-runner',
description: 'Run automated tasks',
content: multilineContent
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-task-runner.md');
expect(result.content).toBe(
'---\ndescription: Run automated tasks\n---\n' + multilineContent
);
});
it('should preserve code blocks in content with frontmatter', () => {
// Arrange
const profile = new OpenCodeProfile();
const contentWithCode = `# Deploy
Run the deployment:
\`\`\`bash
npm run deploy
\`\`\`
Done!`;
const command = staticCommand({
name: 'deploy',
description: 'Deploy the application',
content: contentWithCode
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Deploy the application\n---\n' + contentWithCode
);
});
it('should preserve special characters in content with frontmatter', () => {
// Arrange
const profile = new OpenCodeProfile();
const contentWithSpecialChars =
'# Special\n\nUse `$HOME` and `$PATH` variables. Also: <tag> & "quotes"';
const command = staticCommand({
name: 'special',
description: 'Command with special chars',
content: contentWithSpecialChars
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Command with special chars\n---\n' +
contentWithSpecialChars
);
});
});
describe('format() method for dynamic commands', () => {
it('should include description in frontmatter and preserve $ARGUMENTS placeholder', () => {
// Arrange
const profile = new OpenCodeProfile();
const command = dynamicCommand(
'review',
'Review a pull request',
'<pr-number>',
'# Review PR\n\nReviewing PR: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-review.md');
expect(result.content).toBe(
'---\ndescription: Review a pull request\n---\n# Review PR\n\nReviewing PR: $ARGUMENTS'
);
expect(result.content).toContain('$ARGUMENTS');
});
it('should preserve multiple $ARGUMENTS placeholders with frontmatter', () => {
// Arrange
const profile = new OpenCodeProfile();
const command = dynamicCommand(
'compare',
'Compare two items',
'<item1> <item2>',
'First: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Compare two items\n---\nFirst: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
expect(result.content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
it('should preserve $ARGUMENTS in complex markdown content with frontmatter', () => {
// Arrange
const profile = new OpenCodeProfile();
const complexContent = `# Search Command
## Input
User provided: $ARGUMENTS
## Steps
1. Parse the input: \`$ARGUMENTS\`
2. Search for matches
3. Display results
\`\`\`
Query: $ARGUMENTS
\`\`\``;
const command = dynamicCommand(
'search',
'Search the codebase',
'<query>',
complexContent
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Search the codebase\n---\n' + complexContent
);
expect(result.content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
});
describe('formatAll() method', () => {
it('should format multiple commands correctly with frontmatter', () => {
// Arrange
const profile = new OpenCodeProfile();
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help Content'
}),
dynamicCommand('run', 'Run a command', '<cmd>', 'Running: $ARGUMENTS')
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(2);
expect(results[0].filename).toBe('tm-help.md');
expect(results[0].content).toBe(
'---\ndescription: Show help\n---\n# Help Content'
);
expect(results[1].filename).toBe('tm-run.md');
expect(results[1].content).toBe(
'---\ndescription: Run a command\n---\nRunning: $ARGUMENTS'
);
});
it('should return empty array for empty input', () => {
// Arrange
const profile = new OpenCodeProfile();
// Act
const results = profile.formatAll([]);
// Assert
expect(results).toEqual([]);
});
});
describe('getCommandsPath() method', () => {
it('should return correct absolute path for commands directory', () => {
// Arrange
const profile = new OpenCodeProfile();
const projectRoot = '/home/user/my-project';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
expect(commandsPath).toBe('/home/user/my-project/.opencode/command');
});
it('should handle project root with trailing slash', () => {
// Arrange
const profile = new OpenCodeProfile();
const projectRoot = '/home/user/my-project/';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
// path.join normalizes the path
expect(commandsPath).toBe('/home/user/my-project/.opencode/command');
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* @fileoverview OpenCode Profile
* Slash command profile for OpenCode.
*
* Format:
* ```
* ---
* description: "..."
* ---
* [content]
* ```
*
* OpenCode uses YAML frontmatter format with description field.
* Additional fields (agent, model, subtask) are optional.
*
* Location: .opencode/command/*.md (note: singular "command", not "commands")
*/
import type { FormattedSlashCommand, SlashCommand } from '../types.js';
import { BaseSlashCommandProfile } from './base-profile.js';
/**
* OpenCode profile for slash commands.
*
* OpenCode uses YAML frontmatter for command metadata:
* - description: Short description for the command picker
* - agent (optional): Which agent should handle this command
* - model (optional): Override model for this command
* - subtask (optional): Whether to run as a subtask
*
* Supports $ARGUMENTS and positional args ($1, $2, etc.) placeholders.
*/
export class OpenCodeProfile extends BaseSlashCommandProfile {
readonly name = 'opencode';
readonly displayName = 'OpenCode';
readonly commandsDir = '.opencode/command';
readonly extension = '.md';
readonly supportsNestedCommands = false;
format(command: SlashCommand): FormattedSlashCommand {
const frontmatter = this.buildFrontmatter(command);
const content = this.transformArgumentPlaceholder(command.content);
return {
filename: this.getFilename(command.metadata.name),
content: `${frontmatter}${content}`
};
}
/**
* Build YAML frontmatter for OpenCode format.
* Includes description (required).
*/
private buildFrontmatter(command: SlashCommand): string {
const lines = [
'---',
`description: ${command.metadata.description}`,
'---',
''
];
return lines.join('\n');
}
}

View File

@@ -0,0 +1,368 @@
/**
* @fileoverview Unit Tests for RooProfile
* Tests the Roo Code slash command profile formatting and metadata.
*/
import { describe, it, expect } from 'vitest';
import { RooProfile } from './roo-profile.js';
import { staticCommand, dynamicCommand } from '../factories.js';
describe('RooProfile', () => {
describe('Profile Metadata', () => {
it('should have correct profile name', () => {
// Arrange
const profile = new RooProfile();
// Act & Assert
expect(profile.name).toBe('roo');
});
it('should have correct display name', () => {
// Arrange
const profile = new RooProfile();
// Act & Assert
expect(profile.displayName).toBe('Roo Code');
});
it('should have correct commands directory', () => {
// Arrange
const profile = new RooProfile();
// Act & Assert
expect(profile.commandsDir).toBe('.roo/commands');
});
it('should have correct file extension', () => {
// Arrange
const profile = new RooProfile();
// Act & Assert
expect(profile.extension).toBe('.md');
});
});
describe('supportsCommands getter', () => {
it('should return true when commandsDir is non-empty', () => {
// Arrange
const profile = new RooProfile();
// Act
const result = profile.supportsCommands;
// Assert
expect(result).toBe(true);
});
});
describe('supportsNestedCommands property', () => {
it('should be false (uses tm- prefix instead of subdirectory)', () => {
// Arrange
const profile = new RooProfile();
// Act & Assert
expect(profile.supportsNestedCommands).toBe(false);
});
});
describe('getFilename() method', () => {
it('should add tm- prefix and .md extension to command name', () => {
// Arrange
const profile = new RooProfile();
// Act
const filename = profile.getFilename('help');
// Assert
expect(filename).toBe('tm-help.md');
});
it('should handle command names with hyphens', () => {
// Arrange
const profile = new RooProfile();
// Act
const filename = profile.getFilename('my-command');
// Assert
expect(filename).toBe('tm-my-command.md');
});
it('should handle command names with underscores', () => {
// Arrange
const profile = new RooProfile();
// Act
const filename = profile.getFilename('my_command');
// Assert
expect(filename).toBe('tm-my_command.md');
});
});
describe('format() method for static commands', () => {
it('should add frontmatter with description for simple static command', () => {
// Arrange
const profile = new RooProfile();
const command = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help\n\nList of available commands...'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-help.md');
expect(result.content).toBe(
'---\ndescription: Show available commands\n---\n\n# Help\n\nList of available commands...'
);
});
it('should preserve multiline content with frontmatter', () => {
// Arrange
const profile = new RooProfile();
const multilineContent = `# Task Runner
## Description
Run automated tasks for the project.
## Steps
1. Check dependencies
2. Run build
3. Execute tests
4. Generate report`;
const command = staticCommand({
name: 'task-runner',
description: 'Run automated tasks',
content: multilineContent
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-task-runner.md');
expect(result.content).toBe(
'---\ndescription: Run automated tasks\n---\n\n' + multilineContent
);
});
it('should include argument-hint in frontmatter for static command with argumentHint', () => {
// Arrange
const profile = new RooProfile();
const command = staticCommand({
name: 'analyze',
description: 'Analyze codebase',
argumentHint: '[path]',
content: '# Analyze\n\nAnalyze the specified path.'
});
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-analyze.md');
expect(result.content).toBe(
'---\ndescription: Analyze codebase\nargument-hint: [path]\n---\n\n# Analyze\n\nAnalyze the specified path.'
);
});
it('should preserve code blocks in content with frontmatter', () => {
// Arrange
const profile = new RooProfile();
const contentWithCode = `# Deploy
Run the deployment:
\`\`\`bash
npm run deploy
\`\`\`
Done!`;
const command = staticCommand({
name: 'deploy',
description: 'Deploy the application',
content: contentWithCode
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Deploy the application\n---\n\n' + contentWithCode
);
});
it('should preserve special characters in content with frontmatter', () => {
// Arrange
const profile = new RooProfile();
const contentWithSpecialChars =
'# Special\n\nUse `$HOME` and `$PATH` variables. Also: <tag> & "quotes"';
const command = staticCommand({
name: 'special',
description: 'Command with special chars',
content: contentWithSpecialChars
});
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Command with special chars\n---\n\n' +
contentWithSpecialChars
);
});
});
describe('format() method for dynamic commands', () => {
it('should include argument-hint in frontmatter and preserve $ARGUMENTS placeholder', () => {
// Arrange
const profile = new RooProfile();
const command = dynamicCommand(
'review',
'Review a pull request',
'<pr-number>',
'# Review PR\n\nReviewing PR: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.filename).toBe('tm-review.md');
expect(result.content).toBe(
'---\ndescription: Review a pull request\nargument-hint: <pr-number>\n---\n\n# Review PR\n\nReviewing PR: $ARGUMENTS'
);
expect(result.content).toContain('$ARGUMENTS');
});
it('should preserve multiple $ARGUMENTS placeholders with frontmatter', () => {
// Arrange
const profile = new RooProfile();
const command = dynamicCommand(
'compare',
'Compare two items',
'<item1> <item2>',
'First: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Compare two items\nargument-hint: <item1> <item2>\n---\n\nFirst: $ARGUMENTS\nSecond: $ARGUMENTS\nBoth: $ARGUMENTS'
);
expect(result.content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
it('should preserve $ARGUMENTS in complex markdown content with frontmatter', () => {
// Arrange
const profile = new RooProfile();
const complexContent = `# Search Command
## Input
User provided: $ARGUMENTS
## Steps
1. Parse the input: \`$ARGUMENTS\`
2. Search for matches
3. Display results
\`\`\`
Query: $ARGUMENTS
\`\`\``;
const command = dynamicCommand(
'search',
'Search the codebase',
'<query>',
complexContent
);
// Act
const result = profile.format(command);
// Assert
expect(result.content).toBe(
'---\ndescription: Search the codebase\nargument-hint: <query>\n---\n\n' +
complexContent
);
expect(result.content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
});
describe('formatAll() method', () => {
it('should format multiple commands correctly with frontmatter', () => {
// Arrange
const profile = new RooProfile();
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help Content'
}),
dynamicCommand('run', 'Run a command', '<cmd>', 'Running: $ARGUMENTS')
];
// Act
const results = profile.formatAll(commands);
// Assert
expect(results).toHaveLength(2);
expect(results[0].filename).toBe('tm-help.md');
expect(results[0].content).toBe(
'---\ndescription: Show help\n---\n\n# Help Content'
);
expect(results[1].filename).toBe('tm-run.md');
expect(results[1].content).toBe(
'---\ndescription: Run a command\nargument-hint: <cmd>\n---\n\nRunning: $ARGUMENTS'
);
});
it('should return empty array for empty input', () => {
// Arrange
const profile = new RooProfile();
// Act
const results = profile.formatAll([]);
// Assert
expect(results).toEqual([]);
});
});
describe('getCommandsPath() method', () => {
it('should return correct absolute path for commands directory', () => {
// Arrange
const profile = new RooProfile();
const projectRoot = '/home/user/my-project';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
expect(commandsPath).toBe('/home/user/my-project/.roo/commands');
});
it('should handle project root with trailing slash', () => {
// Arrange
const profile = new RooProfile();
const projectRoot = '/home/user/my-project/';
// Act
const commandsPath = profile.getCommandsPath(projectRoot);
// Assert
// path.join normalizes the path
expect(commandsPath).toBe('/home/user/my-project/.roo/commands');
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* @fileoverview Roo Code Profile
* Slash command profile for Roo Code.
*
* Format:
* ```
* ---
* description: Short description for command picker
* argument-hint: <optional-hint>
* ---
*
* [content]
* ```
*
* Roo Code uses YAML frontmatter for metadata, similar to other markdown-based tools.
* The frontmatter contains a description (required) and optional argument-hint.
*
* Location: .roo/commands/*.md
*/
import { BaseSlashCommandProfile } from './base-profile.js';
import type { SlashCommand, FormattedSlashCommand } from '../types.js';
/**
* Roo Code profile for slash commands.
*
* Roo Code uses YAML frontmatter for command metadata:
* - description: Appears in the command menu to help users understand the command's purpose
* - argument-hint: Optional hint about expected arguments when using the command
*
* The content follows the frontmatter and supports $ARGUMENTS placeholders.
*/
export class RooProfile extends BaseSlashCommandProfile {
readonly name = 'roo';
readonly displayName = 'Roo Code';
readonly commandsDir = '.roo/commands';
readonly extension = '.md';
readonly supportsNestedCommands = false;
format(command: SlashCommand): FormattedSlashCommand {
const frontmatter = this.buildFrontmatter(command);
const content = this.transformArgumentPlaceholder(command.content);
return {
filename: this.getFilename(command.metadata.name),
content: `${frontmatter}${content}`
};
}
/**
* Build YAML frontmatter for Roo Code format.
* Includes description (required) and optional argument-hint.
* Adds a blank line after the closing --- for proper markdown separation.
*/
private buildFrontmatter(command: SlashCommand): string {
const lines = ['---', `description: ${command.metadata.description}`];
if (command.metadata.argumentHint) {
lines.push(`argument-hint: ${command.metadata.argumentHint}`);
}
// Add closing --- and two empty strings to produce "---\n\n" (blank line before content)
lines.push('---', '', '');
return lines.join('\n');
}
}

View File

@@ -0,0 +1,66 @@
/**
* @fileoverview Slash Command Type Definitions
* Uses discriminated unions for type-safe static vs dynamic commands.
*/
/**
* Operating mode for Task Master
* - 'solo': Local file-based storage (Taskmaster standalone)
* - 'team': API-based storage via Hamster (collaborative features)
* - 'common': Works in both modes
*/
export type OperatingMode = 'solo' | 'team' | 'common';
/**
* Base metadata shared by all slash commands
*/
export interface SlashCommandMetadata {
/** Command name (filename without extension) */
readonly name: string;
/** Short description shown in command picker */
readonly description: string;
/** Optional hint for arguments (e.g., "[brief-url]") */
readonly argumentHint?: string;
/** Operating mode - defaults to 'common' if not specified */
readonly mode?: OperatingMode;
}
/**
* A static slash command with fixed content (no $ARGUMENTS placeholder)
* May still have an argumentHint for documentation purposes
*/
export interface StaticSlashCommand {
readonly type: 'static';
readonly metadata: SlashCommandMetadata;
/** The markdown content */
readonly content: string;
}
/**
* A dynamic slash command that accepts arguments via $ARGUMENTS placeholder
*/
export interface DynamicSlashCommand {
readonly type: 'dynamic';
readonly metadata: SlashCommandMetadata & {
/** Hint for arguments - required for dynamic commands */
readonly argumentHint: string;
};
/** The markdown content containing $ARGUMENTS placeholder(s) */
readonly content: string;
}
/**
* Union type for all slash commands
* Use `command.type` to narrow the type
*/
export type SlashCommand = StaticSlashCommand | DynamicSlashCommand;
/**
* Formatted command output ready to be written to file
*/
export interface FormattedSlashCommand {
/** Filename (e.g., "goham.md") */
readonly filename: string;
/** Formatted content for the target editor */
readonly content: string;
}

View File

@@ -0,0 +1,46 @@
/**
* @fileoverview Utility functions for slash commands module
*/
import path from 'path';
/**
* Resolve project root from a target directory by navigating up
* based on a known relative path structure.
*
* This is useful when lifecycle hooks receive a nested directory
* (like `.roo/rules`) and need to get back to the project root
* to place commands in the correct location.
*
* @param targetDir - The target directory (usually rulesDir)
* @param relativePath - The relative path from project root (e.g., ".roo/rules")
* @returns The project root directory
*
* @example
* ```typescript
* // If targetDir is "/project/.roo/rules" and relativePath is ".roo/rules"
* const projectRoot = resolveProjectRoot("/project/.roo/rules", ".roo/rules");
* // Returns: "/project"
*
* // If relativePath is "." then targetDir is already project root
* const projectRoot = resolveProjectRoot("/project", ".");
* // Returns: "/project"
* ```
*/
export function resolveProjectRoot(
targetDir: string,
relativePath: string
): string {
// If relativePath is just "." then targetDir is already the project root
if (relativePath === '.') {
return targetDir;
}
// Count how many directory levels we need to go up
const levels = relativePath.split(path.sep).filter(Boolean).length;
let projectRoot = targetDir;
for (let i = 0; i < levels; i++) {
projectRoot = path.dirname(projectRoot);
}
return projectRoot;
}

View File

@@ -0,0 +1,442 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ClaudeProfile } from '../../src/slash-commands/profiles/claude-profile.js';
import {
staticCommand,
dynamicCommand
} from '../../src/slash-commands/factories.js';
describe('ClaudeProfile Integration Tests', () => {
let tempDir: string;
let claudeProfile: ClaudeProfile;
beforeEach(() => {
// Create a temporary directory for testing
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-profile-test-'));
claudeProfile = new ClaudeProfile();
});
afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('addSlashCommands', () => {
it('should create the .claude/commands/tm directory (nested structure)', async () => {
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: '# Test Content'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
// Claude supports nested commands, so files go to .claude/commands/tm/
const commandsDir = path.join(tempDir, '.claude', 'commands', 'tm');
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
});
it('should write correctly formatted static command files', async () => {
const testCommands = [
staticCommand({
name: 'static-test',
description: 'Static test command',
content: '# Static Content\n\nThis is a test.'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
// Files go to .claude/commands/tm/ subdirectory
const filePath = path.join(
tempDir,
'.claude',
'commands',
'tm',
'static-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
const expectedContent =
'Static test command\n# Static Content\n\nThis is a test.';
expect(content).toBe(expectedContent);
});
it('should write correctly formatted dynamic command files with argumentHint', () => {
const testCommands = [
dynamicCommand(
'dynamic-test',
'Dynamic test command',
'[task-id]',
'Process task: $ARGUMENTS\n\nThis processes the specified task.'
)
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
// Files go to .claude/commands/tm/ subdirectory
const filePath = path.join(
tempDir,
'.claude',
'commands',
'tm',
'dynamic-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
const expectedContent =
'Dynamic test command\n\n' +
'Arguments: $ARGUMENTS\n' +
'Process task: $ARGUMENTS\n\n' +
'This processes the specified task.';
expect(content).toBe(expectedContent);
});
it('should return success result with correct count', () => {
const testCommands = [
staticCommand({
name: 'cmd1',
description: 'First command',
content: 'Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'Second command',
content: 'Content 2'
}),
dynamicCommand('cmd3', 'Third command', '[arg]', 'Content $ARGUMENTS')
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(3);
expect(result.files).toHaveLength(3);
});
it('should overwrite existing files on re-run', () => {
const initialCommands = [
staticCommand({
name: 'test-cmd',
description: 'Initial description',
content: 'Initial content'
})
];
claudeProfile.addSlashCommands(tempDir, initialCommands);
// Files go to .claude/commands/tm/ subdirectory
const filePath = path.join(
tempDir,
'.claude',
'commands',
'tm',
'test-cmd.md'
);
const initialContent = fs.readFileSync(filePath, 'utf-8');
expect(initialContent).toContain('Initial description');
expect(initialContent).toContain('Initial content');
// Re-run with updated command
const updatedCommands = [
staticCommand({
name: 'test-cmd',
description: 'Updated description',
content: 'Updated content'
})
];
claudeProfile.addSlashCommands(tempDir, updatedCommands);
const updatedContent = fs.readFileSync(filePath, 'utf-8');
expect(updatedContent).toContain('Updated description');
expect(updatedContent).toContain('Updated content');
expect(updatedContent).not.toContain('Initial');
});
it('should handle multiple commands with mixed types', async () => {
const testCommands = [
staticCommand({
name: 'static1',
description: 'Static command 1',
content: 'Static content 1'
}),
dynamicCommand(
'dynamic1',
'Dynamic command 1',
'[id]',
'Dynamic content $ARGUMENTS'
),
staticCommand({
name: 'static2',
description: 'Static command 2',
content: 'Static content 2'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(3);
// Files go to .claude/commands/tm/ subdirectory
const commandsDir = path.join(tempDir, '.claude', 'commands', 'tm');
const static1Path = path.join(commandsDir, 'static1.md');
const dynamic1Path = path.join(commandsDir, 'dynamic1.md');
const static2Path = path.join(commandsDir, 'static2.md');
expect(fs.existsSync(static1Path)).toBe(true);
expect(fs.existsSync(dynamic1Path)).toBe(true);
expect(fs.existsSync(static2Path)).toBe(true);
// Verify dynamic command format
const dynamic1Content = fs.readFileSync(dynamic1Path, 'utf-8');
expect(dynamic1Content).toContain('Arguments: $ARGUMENTS');
});
});
describe('removeSlashCommands', () => {
it('should remove only TaskMaster commands and preserve user files', async () => {
// Add TaskMaster commands
const tmCommands = [
staticCommand({
name: 'tm-cmd1',
description: 'TaskMaster command 1',
content: 'TM Content 1'
}),
staticCommand({
name: 'tm-cmd2',
description: 'TaskMaster command 2',
content: 'TM Content 2'
})
];
claudeProfile.addSlashCommands(tempDir, tmCommands);
// TaskMaster commands go to .claude/commands/tm/ subdirectory
const tmDir = path.join(tempDir, '.claude', 'commands', 'tm');
const userFilePath = path.join(tmDir, 'user-custom.md');
fs.writeFileSync(userFilePath, 'User custom command\n\nUser content');
// Remove TaskMaster commands
const result = claudeProfile.removeSlashCommands(tempDir, tmCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
// Verify TaskMaster files are removed
expect(fs.existsSync(path.join(tmDir, 'tm-cmd1.md'))).toBe(false);
expect(fs.existsSync(path.join(tmDir, 'tm-cmd2.md'))).toBe(false);
// Verify user file is preserved
expect(fs.existsSync(userFilePath)).toBe(true);
const userContent = fs.readFileSync(userFilePath, 'utf-8');
expect(userContent).toContain('User custom command');
});
it('should remove empty tm directory after cleanup', async () => {
const testCommands = [
staticCommand({
name: 'only-cmd',
description: 'Only command',
content: 'Only content'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
// Commands go to .claude/commands/tm/
const tmDir = path.join(tempDir, '.claude', 'commands', 'tm');
expect(fs.existsSync(tmDir)).toBe(true);
// Remove all TaskMaster commands
claudeProfile.removeSlashCommands(tempDir, testCommands);
// tm directory should be removed when empty
expect(fs.existsSync(tmDir)).toBe(false);
});
it('should keep tm directory when user files remain', async () => {
const tmCommands = [
staticCommand({
name: 'tm-cmd',
description: 'TaskMaster command',
content: 'TM Content'
})
];
claudeProfile.addSlashCommands(tempDir, tmCommands);
// Add user file in the tm directory
const tmDir = path.join(tempDir, '.claude', 'commands', 'tm');
const userFilePath = path.join(tmDir, 'my-command.md');
fs.writeFileSync(userFilePath, 'My custom command');
// Remove TaskMaster commands
const result = claudeProfile.removeSlashCommands(tempDir, tmCommands);
// Directory should still exist because user file remains
expect(fs.existsSync(tmDir)).toBe(true);
expect(fs.existsSync(userFilePath)).toBe(true);
});
it('should handle removal when no files exist', async () => {
const testCommands = [
staticCommand({
name: 'nonexistent',
description: 'Non-existent command',
content: 'Content'
})
];
// Don't add commands, just try to remove
const result = claudeProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should handle removal when directory does not exist', async () => {
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: 'Content'
})
];
// Ensure .claude/commands/tm doesn't exist
const tmDir = path.join(tempDir, '.claude', 'commands', 'tm');
expect(fs.existsSync(tmDir)).toBe(false);
const result = claudeProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should remove mixed command types', () => {
const testCommands = [
staticCommand({
name: 'static-cmd',
description: 'Static command',
content: 'Static content'
}),
dynamicCommand(
'dynamic-cmd',
'Dynamic command',
'[arg]',
'Dynamic content $ARGUMENTS'
)
];
claudeProfile.addSlashCommands(tempDir, testCommands);
// Files go to .claude/commands/tm/ subdirectory
const tmDir = path.join(tempDir, '.claude', 'commands', 'tm');
expect(fs.existsSync(path.join(tmDir, 'static-cmd.md'))).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'dynamic-cmd.md'))).toBe(true);
const result = claudeProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(fs.existsSync(path.join(tmDir, 'static-cmd.md'))).toBe(false);
expect(fs.existsSync(path.join(tmDir, 'dynamic-cmd.md'))).toBe(false);
});
});
describe('edge cases', () => {
it('should handle empty command list', () => {
const result = claudeProfile.addSlashCommands(tempDir, []);
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should handle commands with special characters in names', async () => {
const testCommands = [
staticCommand({
name: 'test-cmd-123',
description: 'Test with numbers',
content: 'Content'
}),
staticCommand({
name: 'test_underscore',
description: 'Test with underscore',
content: 'Content'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
// Files go to .claude/commands/tm/ subdirectory
const tmDir = path.join(tempDir, '.claude', 'commands', 'tm');
expect(fs.existsSync(path.join(tmDir, 'test-cmd-123.md'))).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'test_underscore.md'))).toBe(true);
});
it('should handle commands with multiline content', async () => {
const testCommands = [
staticCommand({
name: 'multiline',
description: 'Multiline command',
content: 'Line 1\nLine 2\nLine 3\n\nParagraph 2'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
// Files go to .claude/commands/tm/ subdirectory
const filePath = path.join(
tempDir,
'.claude',
'commands',
'tm',
'multiline.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('Line 1\nLine 2\nLine 3');
expect(content).toContain('Paragraph 2');
});
it('should preserve exact formatting in content', async () => {
const testCommands = [
staticCommand({
name: 'formatted',
description: 'Formatted command',
content: '# Heading\n\n- Item 1\n- Item 2\n\n```code\nblock\n```'
})
];
const result = claudeProfile.addSlashCommands(tempDir, testCommands);
// Files go to .claude/commands/tm/ subdirectory
const filePath = path.join(
tempDir,
'.claude',
'commands',
'tm',
'formatted.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('# Heading');
expect(content).toContain('- Item 1\n- Item 2');
expect(content).toContain('```code\nblock\n```');
});
});
});

View File

@@ -0,0 +1,515 @@
/**
* @fileoverview Integration tests for CodexProfile
* Tests actual filesystem operations for slash command management.
*
* Note: Codex stores prompts in ~/.codex/prompts (home directory), not project-relative.
* Tests use the homeDir option to redirect writes to a temp directory.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodexProfile } from '../../src/slash-commands/profiles/codex-profile.js';
import {
staticCommand,
dynamicCommand
} from '../../src/slash-commands/factories.js';
describe('CodexProfile Integration Tests', () => {
let tempDir: string;
let codexProfile: CodexProfile;
beforeEach(() => {
// Create a temporary directory to act as the "home" directory for testing
// Codex prompts go in ~/.codex/prompts, so we override homeDir to tempDir
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-profile-test-'));
codexProfile = new CodexProfile({ homeDir: tempDir });
});
afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('addSlashCommands', () => {
it('should create the .codex/prompts directory', () => {
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: '# Test Content'
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.codex', 'prompts');
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
expect(result.success).toBe(true);
});
it('should write files with YAML frontmatter and tm- prefix', () => {
const testCommands = [
staticCommand({
name: 'static-test',
description: 'Test description',
content: '# Test Content'
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
// Codex uses tm- prefix since supportsNestedCommands = false
const filePath = path.join(
tempDir,
'.codex',
'prompts',
'tm-static-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
expect(result.success).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// Verify YAML frontmatter structure
expect(content).toContain('---');
expect(content).toContain('description: "Test description"');
expect(content).toContain('# Test Content');
// Verify it does NOT include argument-hint (static command without argumentHint)
expect(content).not.toContain('argument-hint:');
// Verify exact format
const expectedContent =
'---\ndescription: "Test description"\n---\n# Test Content';
expect(content).toBe(expectedContent);
});
it('should include argument-hint only when argumentHint is present', () => {
const testCommands = [
staticCommand({
name: 'with-hint',
description: 'Command with hint',
argumentHint: '[args]',
content: 'Content here'
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
const filePath = path.join(
tempDir,
'.codex',
'prompts',
'tm-with-hint.md'
);
expect(fs.existsSync(filePath)).toBe(true);
expect(result.success).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// Verify argument-hint is included
expect(content).toContain('argument-hint: "[args]"');
// Verify exact format
const expectedContent =
'---\ndescription: "Command with hint"\nargument-hint: "[args]"\n---\nContent here';
expect(content).toBe(expectedContent);
});
it('should format dynamic commands with argument-hint', () => {
const testCommands = [
dynamicCommand(
'dynamic-test',
'Dynamic command',
'<task-id>',
'Process: $ARGUMENTS'
)
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
const filePath = path.join(
tempDir,
'.codex',
'prompts',
'tm-dynamic-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
expect(result.success).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// Dynamic commands should include argument-hint
expect(content).toContain('argument-hint: "<task-id>"');
expect(content).toContain('Process: $ARGUMENTS');
// Verify exact format
const expectedContent =
'---\ndescription: "Dynamic command"\nargument-hint: "<task-id>"\n---\nProcess: $ARGUMENTS';
expect(content).toBe(expectedContent);
});
it('should return success result with correct count', () => {
const testCommands = [
staticCommand({
name: 'cmd1',
description: 'First command',
content: 'Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'Second command',
content: 'Content 2'
}),
dynamicCommand('cmd3', 'Third command', '[arg]', 'Content $ARGUMENTS')
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(3);
expect(result.files).toHaveLength(3);
expect(result.directory).toBe(path.join(tempDir, '.codex', 'prompts'));
expect(result.files).toContain('tm-cmd1.md');
expect(result.files).toContain('tm-cmd2.md');
expect(result.files).toContain('tm-cmd3.md');
});
it('should handle multiline content in YAML frontmatter format', () => {
const testCommands = [
staticCommand({
name: 'multiline',
description: 'Multiline test',
content: '# Title\n\nParagraph 1\n\nParagraph 2'
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
const filePath = path.join(
tempDir,
'.codex',
'prompts',
'tm-multiline.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(result.success).toBe(true);
expect(content).toContain('# Title');
expect(content).toContain('Paragraph 1');
expect(content).toContain('Paragraph 2');
});
it('should handle commands with special characters in descriptions', () => {
const testCommands = [
staticCommand({
name: 'special',
description: 'Command with "quotes" and special chars',
content: 'Content'
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
const filePath = path.join(tempDir, '.codex', 'prompts', 'tm-special.md');
const content = fs.readFileSync(filePath, 'utf-8');
expect(result.success).toBe(true);
expect(content).toContain(
'description: "Command with \\"quotes\\" and special chars"'
);
});
});
describe('removeSlashCommands', () => {
it('should remove only TaskMaster commands and preserve user files', () => {
// Add TaskMaster commands
const tmCommands = [
staticCommand({
name: 'cmd1',
description: 'TaskMaster command 1',
content: 'TM Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'TaskMaster command 2',
content: 'TM Content 2'
})
];
codexProfile.addSlashCommands(tempDir, tmCommands);
// Create a user file manually
const commandsDir = path.join(tempDir, '.codex', 'prompts');
const userFilePath = path.join(commandsDir, 'user-custom.md');
fs.writeFileSync(
userFilePath,
'---\ndescription: "User command"\n---\nUser content'
);
// Remove TaskMaster commands
const result = codexProfile.removeSlashCommands(tempDir, tmCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
// Verify TaskMaster files are removed (they have tm- prefix)
expect(fs.existsSync(path.join(commandsDir, 'tm-cmd1.md'))).toBe(false);
expect(fs.existsSync(path.join(commandsDir, 'tm-cmd2.md'))).toBe(false);
// Verify user file is preserved
expect(fs.existsSync(userFilePath)).toBe(true);
const userContent = fs.readFileSync(userFilePath, 'utf-8');
expect(userContent).toContain('User command');
});
it('should remove empty directory after cleanup', () => {
const testCommands = [
staticCommand({
name: 'only-cmd',
description: 'Only command',
content: 'Only content'
})
];
codexProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.codex', 'prompts');
expect(fs.existsSync(commandsDir)).toBe(true);
// Remove all TaskMaster commands
const result = codexProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should be removed when empty
expect(fs.existsSync(commandsDir)).toBe(false);
});
it('should keep directory when user files remain', () => {
const tmCommands = [
staticCommand({
name: 'cmd',
description: 'TaskMaster command',
content: 'TM Content'
})
];
codexProfile.addSlashCommands(tempDir, tmCommands);
// Add user file
const commandsDir = path.join(tempDir, '.codex', 'prompts');
const userFilePath = path.join(commandsDir, 'my-command.md');
fs.writeFileSync(
userFilePath,
'---\ndescription: "My custom command"\n---\nMy content'
);
// Remove TaskMaster commands
const result = codexProfile.removeSlashCommands(tempDir, tmCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.existsSync(userFilePath)).toBe(true);
});
it('should handle removal when no files exist', () => {
const testCommands = [
staticCommand({
name: 'nonexistent',
description: 'Non-existent command',
content: 'Content'
})
];
// Don't add commands, just try to remove
const result = codexProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should handle removal when directory does not exist', () => {
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: 'Content'
})
];
// Ensure .codex/prompts doesn't exist
const commandsDir = path.join(tempDir, '.codex', 'prompts');
expect(fs.existsSync(commandsDir)).toBe(false);
const result = codexProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should remove mixed command types', () => {
const testCommands = [
staticCommand({
name: 'static-cmd',
description: 'Static command',
content: 'Static content'
}),
dynamicCommand(
'dynamic-cmd',
'Dynamic command',
'[arg]',
'Dynamic content $ARGUMENTS'
)
];
codexProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.codex', 'prompts');
expect(fs.existsSync(path.join(commandsDir, 'tm-static-cmd.md'))).toBe(
true
);
expect(fs.existsSync(path.join(commandsDir, 'tm-dynamic-cmd.md'))).toBe(
true
);
const result = codexProfile.removeSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(fs.existsSync(path.join(commandsDir, 'tm-static-cmd.md'))).toBe(
false
);
expect(fs.existsSync(path.join(commandsDir, 'tm-dynamic-cmd.md'))).toBe(
false
);
});
});
describe('edge cases', () => {
it('should handle empty command list', () => {
const result = codexProfile.addSlashCommands(tempDir, []);
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should handle commands with hyphens and underscores in names', () => {
const testCommands = [
staticCommand({
name: 'test-cmd-123',
description: 'Test with numbers',
content: 'Content'
}),
staticCommand({
name: 'test_underscore',
description: 'Test with underscore',
content: 'Content'
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
const commandsDir = path.join(tempDir, '.codex', 'prompts');
expect(fs.existsSync(path.join(commandsDir, 'tm-test-cmd-123.md'))).toBe(
true
);
expect(
fs.existsSync(path.join(commandsDir, 'tm-test_underscore.md'))
).toBe(true);
});
it('should preserve exact formatting in content', () => {
const testCommands = [
staticCommand({
name: 'formatted',
description: 'Formatted command',
content: '# Heading\n\n- Item 1\n- Item 2\n\n```code\nblock\n```'
})
];
codexProfile.addSlashCommands(tempDir, testCommands);
const filePath = path.join(
tempDir,
'.codex',
'prompts',
'tm-formatted.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('# Heading');
expect(content).toContain('- Item 1\n- Item 2');
expect(content).toContain('```code\nblock\n```');
});
it('should handle empty content', () => {
const testCommands = [
staticCommand({
name: 'empty',
description: 'Empty content',
content: ''
})
];
const result = codexProfile.addSlashCommands(tempDir, testCommands);
const filePath = path.join(tempDir, '.codex', 'prompts', 'tm-empty.md');
const content = fs.readFileSync(filePath, 'utf-8');
expect(result.success).toBe(true);
// Should only have frontmatter
expect(content).toBe('---\ndescription: "Empty content"\n---\n');
});
it('should overwrite existing files on re-run', () => {
const initialCommands = [
staticCommand({
name: 'test-cmd',
description: 'Initial description',
content: 'Initial content'
})
];
codexProfile.addSlashCommands(tempDir, initialCommands);
const filePath = path.join(
tempDir,
'.codex',
'prompts',
'tm-test-cmd.md'
);
const initialContent = fs.readFileSync(filePath, 'utf-8');
expect(initialContent).toContain('Initial description');
expect(initialContent).toContain('Initial content');
// Re-run with updated command
const updatedCommands = [
staticCommand({
name: 'test-cmd',
description: 'Updated description',
content: 'Updated content'
})
];
codexProfile.addSlashCommands(tempDir, updatedCommands);
const updatedContent = fs.readFileSync(filePath, 'utf-8');
expect(updatedContent).toContain('Updated description');
expect(updatedContent).toContain('Updated content');
expect(updatedContent).not.toContain('Initial');
});
});
});

View File

@@ -0,0 +1,351 @@
/**
* @fileoverview Integration tests for CursorProfile
*
* These tests verify actual filesystem operations using addSlashCommands
* and removeSlashCommands methods. Tests ensure that:
* - Directory creation works correctly (files go to .cursor/commands/tm/)
* - Files are written with correct content (no transformation)
* - Commands can be added and removed
* - User files are preserved during cleanup
* - Empty directories are cleaned up
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CursorProfile } from '../../src/slash-commands/profiles/cursor-profile.js';
import {
staticCommand,
dynamicCommand
} from '../../src/slash-commands/factories.js';
describe('CursorProfile - Integration Tests', () => {
let tempDir: string;
let cursorProfile: CursorProfile;
// Test commands created inline
const testStaticCommand = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help\n\nList of available Task Master commands.'
});
const testDynamicCommand = dynamicCommand(
'goham',
'Start Working with Hamster Brief',
'[brief-url]',
'# Start Working\n\nBrief URL: $ARGUMENTS\n\nThis command helps you start working on a Hamster brief.'
);
const testCommands = [testStaticCommand, testDynamicCommand];
beforeEach(() => {
// Create temporary directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-profile-test-'));
cursorProfile = new CursorProfile();
});
afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('addSlashCommands', () => {
it('should create the .cursor/commands/tm directory (nested structure)', () => {
// Verify directory doesn't exist before
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
expect(fs.existsSync(tmDir)).toBe(false);
// Add commands
cursorProfile.addSlashCommands(tempDir, testCommands);
// Verify tm directory exists after (nested structure)
expect(fs.existsSync(tmDir)).toBe(true);
expect(fs.statSync(tmDir).isDirectory()).toBe(true);
});
it('should write files with content unchanged (no transformation)', () => {
cursorProfile.addSlashCommands(tempDir, testCommands);
// Cursor supports nested commands, files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// Verify static command (help.md)
const helpPath = path.join(tmDir, 'help.md');
expect(fs.existsSync(helpPath)).toBe(true);
const helpContent = fs.readFileSync(helpPath, 'utf-8');
expect(helpContent).toBe(
'# Help\n\nList of available Task Master commands.'
);
// Verify dynamic command (goham.md)
const gohamPath = path.join(tmDir, 'goham.md');
expect(fs.existsSync(gohamPath)).toBe(true);
const gohamContent = fs.readFileSync(gohamPath, 'utf-8');
expect(gohamContent).toBe(
'# Start Working\n\nBrief URL: $ARGUMENTS\n\nThis command helps you start working on a Hamster brief.'
);
// Verify $ARGUMENTS placeholder is NOT transformed
expect(gohamContent).toContain('$ARGUMENTS');
});
it('should return success result with correct count', () => {
const result = cursorProfile.addSlashCommands(tempDir, testCommands);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
// Path includes tm/ subdirectory for nested structure
expect(result.directory).toBe(
path.join(tempDir, '.cursor', 'commands', 'tm')
);
expect(result.files).toEqual(['help.md', 'goham.md']);
expect(result.error).toBeUndefined();
});
it('should overwrite existing files on re-run', () => {
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// First run
cursorProfile.addSlashCommands(tempDir, testCommands);
const originalContent = fs.readFileSync(
path.join(tmDir, 'help.md'),
'utf-8'
);
expect(originalContent).toBe(
'# Help\n\nList of available Task Master commands.'
);
// Modify the content of test command
const modifiedCommand = staticCommand({
name: 'help',
description: 'Show available commands',
content: '# Help - Updated\n\nThis is updated content.'
});
// Second run with modified command
const result = cursorProfile.addSlashCommands(tempDir, [modifiedCommand]);
// Verify file was overwritten
const updatedContent = fs.readFileSync(
path.join(tmDir, 'help.md'),
'utf-8'
);
expect(updatedContent).toBe(
'# Help - Updated\n\nThis is updated content.'
);
expect(result.success).toBe(true);
expect(result.count).toBe(1);
});
it('should handle commands with special characters in content', () => {
const specialCommand = staticCommand({
name: 'special',
description: 'Command with special characters',
content:
'# Special\n\n```bash\necho "Hello $USER"\n```\n\n- Item 1\n- Item 2\n\n**Bold** and *italic*'
});
cursorProfile.addSlashCommands(tempDir, [specialCommand]);
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
const specialPath = path.join(tmDir, 'special.md');
const content = fs.readFileSync(specialPath, 'utf-8');
// Verify content is preserved exactly
expect(content).toBe(
'# Special\n\n```bash\necho "Hello $USER"\n```\n\n- Item 1\n- Item 2\n\n**Bold** and *italic*'
);
});
});
describe('removeSlashCommands', () => {
beforeEach(() => {
// Add commands before testing removal
cursorProfile.addSlashCommands(tempDir, testCommands);
});
it('should remove only TaskMaster commands (preserve user files)', () => {
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// Create a user's custom command file in the tm directory
const userCommandPath = path.join(tmDir, 'custom-user-command.md');
fs.writeFileSync(
userCommandPath,
'# Custom User Command\n\nThis is a user-created command.'
);
// Verify all files exist before removal
expect(fs.existsSync(path.join(tmDir, 'help.md'))).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'goham.md'))).toBe(true);
expect(fs.existsSync(userCommandPath)).toBe(true);
// Remove TaskMaster commands
const result = cursorProfile.removeSlashCommands(tempDir, testCommands);
// Verify TaskMaster commands removed
expect(fs.existsSync(path.join(tmDir, 'help.md'))).toBe(false);
expect(fs.existsSync(path.join(tmDir, 'goham.md'))).toBe(false);
// Verify user's custom file preserved
expect(fs.existsSync(userCommandPath)).toBe(true);
expect(fs.readFileSync(userCommandPath, 'utf-8')).toBe(
'# Custom User Command\n\nThis is a user-created command.'
);
// Verify result
expect(result.success).toBe(true);
expect(result.count).toBe(2);
// File order is not guaranteed, so check both files are present
expect(result.files).toHaveLength(2);
expect(result.files).toContain('help.md');
expect(result.files).toContain('goham.md');
});
it('should remove empty tm directory after cleanup', () => {
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// Verify directory exists with files
expect(fs.existsSync(tmDir)).toBe(true);
expect(fs.readdirSync(tmDir).length).toBe(2);
// Remove all commands (should cleanup empty directory)
const result = cursorProfile.removeSlashCommands(
tempDir,
testCommands,
true
);
// Verify tm directory removed
expect(fs.existsSync(tmDir)).toBe(false);
expect(result.success).toBe(true);
expect(result.count).toBe(2);
});
it('should not remove directory if removeEmptyDir is false', () => {
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// Remove commands but keep directory
const result = cursorProfile.removeSlashCommands(
tempDir,
testCommands,
false
);
// Verify directory still exists (but empty)
expect(fs.existsSync(tmDir)).toBe(true);
expect(fs.statSync(tmDir).isDirectory()).toBe(true);
expect(fs.readdirSync(tmDir).length).toBe(0);
expect(result.success).toBe(true);
});
it('should handle removal when directory does not exist', () => {
const nonExistentDir = path.join(tempDir, 'nonexistent');
// Remove commands from non-existent directory
const result = cursorProfile.removeSlashCommands(
nonExistentDir,
testCommands
);
// Should succeed with 0 count
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toEqual([]);
});
it('should be case-insensitive when matching command names', () => {
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// Check if filesystem is case-sensitive (Linux) or case-insensitive (macOS/Windows)
const testFile = path.join(tmDir, 'TEST-CASE.md');
fs.writeFileSync(testFile, 'test');
const isCaseSensitive = !fs.existsSync(path.join(tmDir, 'test-case.md'));
fs.rmSync(testFile);
// Create command with different casing from test commands
const upperCaseFile = path.join(tmDir, 'HELP.md');
fs.writeFileSync(upperCaseFile, '# Upper case help');
// Remove using lowercase name
const result = cursorProfile.removeSlashCommands(tempDir, testCommands);
// help.md should always be removed
expect(fs.existsSync(path.join(tmDir, 'help.md'))).toBe(false);
if (isCaseSensitive) {
// On case-sensitive filesystems, HELP.md is treated as different file
expect(fs.existsSync(upperCaseFile)).toBe(true);
expect(result.count).toBe(2); // help.md, goham.md
// Clean up
fs.rmSync(upperCaseFile);
} else {
// On case-insensitive filesystems (macOS/Windows), both should be removed
// because the filesystem treats help.md and HELP.md as the same file
expect(fs.existsSync(upperCaseFile)).toBe(false);
expect(result.count).toBe(2); // help.md (which is the same as HELP.md), goham.md
}
});
});
describe('Profile configuration', () => {
it('should have correct profile properties', () => {
expect(cursorProfile.name).toBe('cursor');
expect(cursorProfile.displayName).toBe('Cursor');
expect(cursorProfile.commandsDir).toBe('.cursor/commands');
expect(cursorProfile.extension).toBe('.md');
expect(cursorProfile.supportsCommands).toBe(true);
expect(cursorProfile.supportsNestedCommands).toBe(true);
});
it('should generate correct filenames (no prefix for nested structure)', () => {
// Cursor supports nested commands, so no tm- prefix
expect(cursorProfile.getFilename('help')).toBe('help.md');
expect(cursorProfile.getFilename('goham')).toBe('goham.md');
});
it('should generate correct commands path with tm subdirectory', () => {
// Path includes tm/ subdirectory for nested structure
const expectedPath = path.join(tempDir, '.cursor', 'commands', 'tm');
expect(cursorProfile.getCommandsPath(tempDir)).toBe(expectedPath);
});
});
describe('Round-trip operations', () => {
it('should successfully add, remove, and re-add commands', () => {
// Files go to .cursor/commands/tm/
const tmDir = path.join(tempDir, '.cursor', 'commands', 'tm');
// Add commands
const addResult1 = cursorProfile.addSlashCommands(tempDir, testCommands);
expect(addResult1.success).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'help.md'))).toBe(true);
// Remove commands
const removeResult = cursorProfile.removeSlashCommands(
tempDir,
testCommands
);
expect(removeResult.success).toBe(true);
expect(fs.existsSync(tmDir)).toBe(false);
// Re-add commands
const addResult2 = cursorProfile.addSlashCommands(tempDir, testCommands);
expect(addResult2.success).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'help.md'))).toBe(true);
// Verify content is still correct
const content = fs.readFileSync(path.join(tmDir, 'help.md'), 'utf-8');
expect(content).toBe('# Help\n\nList of available Task Master commands.');
});
});
});

View File

@@ -0,0 +1,489 @@
/**
* @fileoverview Integration Tests for GeminiProfile
* Tests actual filesystem operations for adding and removing slash commands.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { GeminiProfile } from '../../src/slash-commands/profiles/gemini-profile.js';
import {
staticCommand,
dynamicCommand
} from '../../src/slash-commands/factories.js';
describe('GeminiProfile Integration Tests', () => {
let tempDir: string;
let profile: GeminiProfile;
beforeEach(() => {
// Create temporary directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-profile-test-'));
profile = new GeminiProfile();
});
afterEach(() => {
// Clean up temporary directory after each test
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('addSlashCommands()', () => {
it('should create the .gemini/commands directory', () => {
// Arrange
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help Content'
})
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
const commandsDir = path.join(tempDir, '.gemini/commands');
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
});
it('should write files with Python-style format (description="...", prompt = """...""")', () => {
// Arrange
const commands = [
staticCommand({
name: 'test',
description: 'Test description',
content: '# Test Content'
})
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
const filePath = path.join(tempDir, '.gemini/commands/tm/test.toml');
expect(fs.existsSync(filePath)).toBe(true);
const fileContent = fs.readFileSync(filePath, 'utf-8');
expect(fileContent).toBe(
'description="Test description"\nprompt = """\n# Test Content\n"""\n'
);
});
it('should return success result with correct count', () => {
// Arrange
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help'
}),
staticCommand({
name: 'deploy',
description: 'Deploy app',
content: '# Deploy'
}),
dynamicCommand(
'review',
'Review PR',
'<pr-number>',
'# Review\n\nPR: $ARGUMENTS'
)
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(3);
expect(result.files).toHaveLength(3);
expect(result.files).toContain('help.toml');
expect(result.files).toContain('deploy.toml');
expect(result.files).toContain('review.toml');
expect(result.directory).toBe(path.join(tempDir, '.gemini/commands/tm'));
});
it('should properly escape double quotes in description', () => {
// Arrange
const commands = [
staticCommand({
name: 'test',
description: 'Test "quoted" description',
content: '# Test Content'
})
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
const filePath = path.join(tempDir, '.gemini/commands/tm/test.toml');
const fileContent = fs.readFileSync(filePath, 'utf-8');
expect(fileContent).toContain(
'description="Test \\"quoted\\" description"'
);
});
it('should handle multiple commands with different types', () => {
// Arrange
const commands = [
staticCommand({
name: 'static-cmd',
description: 'Static command',
content: '# Static Content\n\nThis is static.'
}),
dynamicCommand(
'dynamic-cmd',
'Dynamic command',
'<arg>',
'# Dynamic Content\n\nArgument: $ARGUMENTS'
)
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
// Verify static command
const staticFilePath = path.join(
tempDir,
'.gemini/commands/tm/static-cmd.toml'
);
const staticContent = fs.readFileSync(staticFilePath, 'utf-8');
expect(staticContent).toContain('description="Static command"');
expect(staticContent).toContain('# Static Content');
expect(staticContent).not.toContain('$ARGUMENTS');
// Verify dynamic command
const dynamicFilePath = path.join(
tempDir,
'.gemini/commands/tm/dynamic-cmd.toml'
);
const dynamicContent = fs.readFileSync(dynamicFilePath, 'utf-8');
expect(dynamicContent).toContain('description="Dynamic command"');
expect(dynamicContent).toContain('Argument: $ARGUMENTS');
});
it('should create directory recursively if parent directories do not exist', () => {
// Arrange
const commands = [
staticCommand({
name: 'test',
description: 'Test',
content: '# Test'
})
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
expect(fs.existsSync(commandsDir)).toBe(true);
});
it('should work when directory already exists', () => {
// Arrange
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
fs.mkdirSync(commandsDir, { recursive: true });
const commands = [
staticCommand({
name: 'test',
description: 'Test',
content: '# Test'
})
];
// Act
const result = profile.addSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
});
});
describe('removeSlashCommands()', () => {
it('should remove only TaskMaster commands (preserves user files)', () => {
// Arrange
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
fs.mkdirSync(commandsDir, { recursive: true });
// Add TaskMaster commands
const tmCommands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help'
}),
staticCommand({
name: 'deploy',
description: 'Deploy',
content: '# Deploy'
})
];
profile.addSlashCommands(tempDir, tmCommands);
// Add user's custom command
const userFilePath = path.join(commandsDir, 'my-custom-command.toml');
fs.writeFileSync(
userFilePath,
'description="My custom command"\nprompt = """\n# Custom\n"""\n'
);
// Act
const result = profile.removeSlashCommands(tempDir, tmCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(result.files).toContain('help.toml');
expect(result.files).toContain('deploy.toml');
// Verify TaskMaster commands are removed
expect(fs.existsSync(path.join(commandsDir, 'help.toml'))).toBe(false);
expect(fs.existsSync(path.join(commandsDir, 'deploy.toml'))).toBe(false);
// Verify user's custom command is preserved
expect(fs.existsSync(userFilePath)).toBe(true);
});
it('should remove empty directory after cleanup', () => {
// Arrange
const commands = [
staticCommand({
name: 'test',
description: 'Test',
content: '# Test'
})
];
// Add commands first
profile.addSlashCommands(tempDir, commands);
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
expect(fs.existsSync(commandsDir)).toBe(true);
// Act
const result = profile.removeSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should be removed since it's empty
expect(fs.existsSync(commandsDir)).toBe(false);
});
it('should not remove directory if user files remain (removeEmptyDir=true)', () => {
// Arrange
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
fs.mkdirSync(commandsDir, { recursive: true });
// Add TaskMaster command
const tmCommands = [
staticCommand({
name: 'help',
description: 'Help',
content: '# Help'
})
];
profile.addSlashCommands(tempDir, tmCommands);
// Add user's custom command
const userFilePath = path.join(commandsDir, 'my-command.toml');
fs.writeFileSync(
userFilePath,
'description="User"\nprompt = """\n# User\n"""\n'
);
// Act
const result = profile.removeSlashCommands(tempDir, tmCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist because user file remains
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.existsSync(userFilePath)).toBe(true);
});
it('should not remove directory if removeEmptyDir=false', () => {
// Arrange
const commands = [
staticCommand({
name: 'test',
description: 'Test',
content: '# Test'
})
];
// Add command first
profile.addSlashCommands(tempDir, commands);
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
// Act
const result = profile.removeSlashCommands(tempDir, commands, false);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist because removeEmptyDir=false
expect(fs.existsSync(commandsDir)).toBe(true);
// Verify directory is empty
const remainingFiles = fs.readdirSync(commandsDir);
expect(remainingFiles).toHaveLength(0);
});
it('should return success with count 0 if directory does not exist', () => {
// Arrange
const commands = [
staticCommand({
name: 'test',
description: 'Test',
content: '# Test'
})
];
// Act (directory doesn't exist)
const result = profile.removeSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
it('should handle removing subset of commands', () => {
// Arrange
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
fs.mkdirSync(commandsDir, { recursive: true });
const allCommands = [
staticCommand({
name: 'help',
description: 'Help',
content: '# Help'
}),
staticCommand({
name: 'deploy',
description: 'Deploy',
content: '# Deploy'
}),
staticCommand({
name: 'test',
description: 'Test',
content: '# Test'
})
];
// Add all commands
profile.addSlashCommands(tempDir, allCommands);
// Remove only 'help' and 'test'
const commandsToRemove = [allCommands[0], allCommands[2]];
// Act
const result = profile.removeSlashCommands(tempDir, commandsToRemove);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(result.files).toContain('help.toml');
expect(result.files).toContain('test.toml');
// Verify removed commands
expect(fs.existsSync(path.join(commandsDir, 'help.toml'))).toBe(false);
expect(fs.existsSync(path.join(commandsDir, 'test.toml'))).toBe(false);
// Verify 'deploy' remains
expect(fs.existsSync(path.join(commandsDir, 'deploy.toml'))).toBe(true);
});
it('should match commands case-insensitively', () => {
// Arrange
const commandsDir = path.join(tempDir, '.gemini/commands/tm');
fs.mkdirSync(commandsDir, { recursive: true });
// Create file with uppercase in name
const upperFilePath = path.join(commandsDir, 'HELP.toml');
fs.writeFileSync(
upperFilePath,
'description="Help"\nprompt = """\n# Help\n"""\n'
);
const commands = [
staticCommand({
name: 'help',
description: 'Help',
content: '# Help'
})
];
// Act
const result = profile.removeSlashCommands(tempDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
expect(fs.existsSync(upperFilePath)).toBe(false);
});
});
describe('Full workflow: add then remove', () => {
it('should successfully add and then remove commands', () => {
// Arrange
const commands = [
staticCommand({
name: 'help',
description: 'Show help',
content: '# Help Content'
}),
dynamicCommand(
'review',
'Review PR',
'<pr-number>',
'# Review\n\nPR: $ARGUMENTS'
)
];
const commandsDir = path.join(tempDir, '.gemini/commands');
const tmDir = path.join(commandsDir, 'tm');
// Act - Add commands
const addResult = profile.addSlashCommands(tempDir, commands);
// Assert - Add worked
expect(addResult.success).toBe(true);
expect(addResult.count).toBe(2);
expect(fs.existsSync(tmDir)).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'help.toml'))).toBe(true);
expect(fs.existsSync(path.join(tmDir, 'review.toml'))).toBe(true);
// Act - Remove commands
const removeResult = profile.removeSlashCommands(tempDir, commands);
// Assert - Remove worked
expect(removeResult.success).toBe(true);
expect(removeResult.count).toBe(2);
// The tm subdirectory should be removed
expect(fs.existsSync(tmDir)).toBe(false);
});
});
});

View File

@@ -0,0 +1,630 @@
/**
* @fileoverview Integration Tests for OpenCodeProfile
* Tests actual filesystem operations using addSlashCommands and removeSlashCommands methods.
*
* OpenCodeProfile details:
* - commandsDir: '.opencode/command' (note: singular "command", not "commands")
* - extension: '.md'
* - Format: YAML frontmatter with description field
* - supportsNestedCommands: false (uses tm- prefix for filenames)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { OpenCodeProfile } from '../../src/slash-commands/profiles/opencode-profile.js';
import {
staticCommand,
dynamicCommand
} from '../../src/slash-commands/factories.js';
describe('OpenCodeProfile Integration Tests', () => {
let tempDir: string;
let openCodeProfile: OpenCodeProfile;
beforeEach(() => {
// Create a temporary directory for testing
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-profile-test-'));
openCodeProfile = new OpenCodeProfile();
});
afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('addSlashCommands', () => {
it('should create the .opencode/command directory', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: '# Test Content'
})
];
// Act
const result = openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
const commandsDir = path.join(tempDir, '.opencode', 'command');
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
expect(result.success).toBe(true);
});
it('should write files with frontmatter and tm- prefix', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'plain-test',
description: 'Plain test command',
content: '# Original Content\n\nThis should remain unchanged.'
})
];
// Act
openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.opencode',
'command',
'tm-plain-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// OpenCode uses YAML frontmatter for metadata (no blank line after frontmatter)
expect(content).toBe(
'---\ndescription: Plain test command\n---\n# Original Content\n\nThis should remain unchanged.'
);
});
it('should include description in frontmatter for dynamic commands', () => {
// Arrange
const testCommands = [
dynamicCommand(
'dynamic-test',
'Dynamic test command',
'[task-id]',
'Process task: $ARGUMENTS\n\nThis processes the specified task.'
)
];
// Act
openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.opencode',
'command',
'tm-dynamic-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// OpenCode uses YAML frontmatter with description only (no argument-hint, no blank line after frontmatter)
expect(content).toBe(
'---\ndescription: Dynamic test command\n---\nProcess task: $ARGUMENTS\n\nThis processes the specified task.'
);
expect(content).toContain('$ARGUMENTS');
});
it('should return success result with correct count', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'cmd1',
description: 'First command',
content: 'Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'Second command',
content: 'Content 2'
}),
dynamicCommand(
'cmd3',
'Third command',
'[arg]',
'Content 3: $ARGUMENTS'
)
];
// Act
const result = openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(3);
expect(result.directory).toBe(path.join(tempDir, '.opencode', 'command'));
expect(result.files).toHaveLength(3);
expect(result.files).toContain('tm-cmd1.md');
expect(result.files).toContain('tm-cmd2.md');
expect(result.files).toContain('tm-cmd3.md');
});
it('should overwrite existing files on re-run', () => {
// Arrange
const initialCommands = [
staticCommand({
name: 'test-cmd',
description: 'Initial description',
content: 'Initial content'
})
];
// Act - First run
openCodeProfile.addSlashCommands(tempDir, initialCommands);
const filePath = path.join(
tempDir,
'.opencode',
'command',
'tm-test-cmd.md'
);
const initialContent = fs.readFileSync(filePath, 'utf-8');
expect(initialContent).toBe(
'---\ndescription: Initial description\n---\nInitial content'
);
// Act - Re-run with updated command
const updatedCommands = [
staticCommand({
name: 'test-cmd',
description: 'Updated description',
content: 'Updated content'
})
];
openCodeProfile.addSlashCommands(tempDir, updatedCommands);
// Assert
const updatedContent = fs.readFileSync(filePath, 'utf-8');
expect(updatedContent).toBe(
'---\ndescription: Updated description\n---\nUpdated content'
);
expect(updatedContent).not.toContain('Initial');
});
it('should handle multiple commands with mixed types', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'static1',
description: 'Static command 1',
content: 'Static content 1'
}),
dynamicCommand(
'dynamic1',
'Dynamic command 1',
'[id]',
'Dynamic content $ARGUMENTS'
),
staticCommand({
name: 'static2',
description: 'Static command 2',
content: 'Static content 2'
})
];
// Act
const result = openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(3);
// Verify all files exist (with tm- prefix)
const static1Path = path.join(
tempDir,
'.opencode',
'command',
'tm-static1.md'
);
const dynamic1Path = path.join(
tempDir,
'.opencode',
'command',
'tm-dynamic1.md'
);
const static2Path = path.join(
tempDir,
'.opencode',
'command',
'tm-static2.md'
);
expect(fs.existsSync(static1Path)).toBe(true);
expect(fs.existsSync(dynamic1Path)).toBe(true);
expect(fs.existsSync(static2Path)).toBe(true);
// Verify content includes frontmatter
const static1Content = fs.readFileSync(static1Path, 'utf-8');
expect(static1Content).toBe(
'---\ndescription: Static command 1\n---\nStatic content 1'
);
const dynamic1Content = fs.readFileSync(dynamic1Path, 'utf-8');
expect(dynamic1Content).toBe(
'---\ndescription: Dynamic command 1\n---\nDynamic content $ARGUMENTS'
);
});
it('should handle empty command list', () => {
// Act
const result = openCodeProfile.addSlashCommands(tempDir, []);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
it('should preserve multiline content with frontmatter', () => {
// Arrange
const multilineContent = `# Task Runner
## Description
Run automated tasks for the project.
## Steps
1. Check dependencies
2. Run build
3. Execute tests
4. Generate report`;
const testCommands = [
staticCommand({
name: 'task-runner',
description: 'Run automated tasks',
content: multilineContent
})
];
// Act
openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.opencode',
'command',
'tm-task-runner.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toBe(
'---\ndescription: Run automated tasks\n---\n' + multilineContent
);
});
it('should preserve code blocks and special characters in content with frontmatter', () => {
// Arrange
const contentWithCode = `# Deploy
Run the deployment:
\`\`\`bash
npm run deploy
\`\`\`
Use \`$HOME\` and \`$PATH\` variables. Also: <tag> & "quotes"`;
const testCommands = [
staticCommand({
name: 'deploy',
description: 'Deploy the application',
content: contentWithCode
})
];
// Act
openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.opencode',
'command',
'tm-deploy.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toBe(
'---\ndescription: Deploy the application\n---\n' + contentWithCode
);
expect(content).toContain('```bash');
expect(content).toContain('$HOME');
expect(content).toContain('<tag> & "quotes"');
});
});
describe('removeSlashCommands', () => {
it('should remove only TaskMaster commands and preserve user files', () => {
// Arrange - Add TaskMaster commands
const tmCommands = [
staticCommand({
name: 'cmd1',
description: 'TaskMaster command 1',
content: 'TM Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'TaskMaster command 2',
content: 'TM Content 2'
})
];
openCodeProfile.addSlashCommands(tempDir, tmCommands);
// Create a user file manually
const commandsDir = path.join(tempDir, '.opencode', 'command');
const userFilePath = path.join(commandsDir, 'user-custom.md');
fs.writeFileSync(userFilePath, 'User custom command\n\nUser content');
// Act - Remove TaskMaster commands
const result = openCodeProfile.removeSlashCommands(tempDir, tmCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(result.files).toHaveLength(2);
// Verify TaskMaster files are removed (tm- prefix is added automatically)
expect(fs.existsSync(path.join(commandsDir, 'tm-cmd1.md'))).toBe(false);
expect(fs.existsSync(path.join(commandsDir, 'tm-cmd2.md'))).toBe(false);
// Verify user file is preserved
expect(fs.existsSync(userFilePath)).toBe(true);
const userContent = fs.readFileSync(userFilePath, 'utf-8');
expect(userContent).toContain('User custom command');
});
it('should remove empty directory after cleanup', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'only-cmd',
description: 'Only command',
content: 'Only content'
})
];
openCodeProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.opencode', 'command');
expect(fs.existsSync(commandsDir)).toBe(true);
// Act - Remove all TaskMaster commands
const result = openCodeProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should be removed when empty (default behavior)
expect(fs.existsSync(commandsDir)).toBe(false);
});
it('should keep directory when user files remain', () => {
// Arrange
const tmCommands = [
staticCommand({
name: 'cmd',
description: 'TaskMaster command',
content: 'TM Content'
})
];
openCodeProfile.addSlashCommands(tempDir, tmCommands);
// Add user file
const commandsDir = path.join(tempDir, '.opencode', 'command');
const userFilePath = path.join(commandsDir, 'my-command.md');
fs.writeFileSync(userFilePath, 'My custom command');
// Act - Remove TaskMaster commands
const result = openCodeProfile.removeSlashCommands(tempDir, tmCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist because user file remains
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.existsSync(userFilePath)).toBe(true);
});
it('should handle removal when no files exist', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'nonexistent',
description: 'Non-existent command',
content: 'Content'
})
];
// Act - Don't add commands, just try to remove
const result = openCodeProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
it('should handle removal when directory does not exist', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: 'Content'
})
];
// Ensure .opencode/command doesn't exist
const commandsDir = path.join(tempDir, '.opencode', 'command');
expect(fs.existsSync(commandsDir)).toBe(false);
// Act
const result = openCodeProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should remove mixed command types', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'static-cmd',
description: 'Static command',
content: 'Static content'
}),
dynamicCommand(
'dynamic-cmd',
'Dynamic command',
'[arg]',
'Dynamic content $ARGUMENTS'
)
];
openCodeProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.opencode', 'command');
expect(fs.existsSync(path.join(commandsDir, 'tm-static-cmd.md'))).toBe(
true
);
expect(fs.existsSync(path.join(commandsDir, 'tm-dynamic-cmd.md'))).toBe(
true
);
// Act
const result = openCodeProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(fs.existsSync(path.join(commandsDir, 'tm-static-cmd.md'))).toBe(
false
);
expect(fs.existsSync(path.join(commandsDir, 'tm-dynamic-cmd.md'))).toBe(
false
);
// Directory should be removed since it's empty
expect(fs.existsSync(commandsDir)).toBe(false);
});
it('should not remove directory when removeEmptyDir is false', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: 'Content'
})
];
openCodeProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.opencode', 'command');
expect(fs.existsSync(commandsDir)).toBe(true);
// Act - Remove with removeEmptyDir=false
const result = openCodeProfile.removeSlashCommands(
tempDir,
testCommands,
false
);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist even though it's empty
expect(fs.existsSync(commandsDir)).toBe(true);
});
});
describe('edge cases', () => {
it('should handle commands with special characters in names', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd-123',
description: 'Test with numbers',
content: 'Content'
}),
staticCommand({
name: 'test_underscore',
description: 'Test with underscore',
content: 'Content'
})
];
// Act
const result = openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
const commandsDir = path.join(tempDir, '.opencode', 'command');
expect(fs.existsSync(path.join(commandsDir, 'tm-test-cmd-123.md'))).toBe(
true
);
expect(
fs.existsSync(path.join(commandsDir, 'tm-test_underscore.md'))
).toBe(true);
});
it('should preserve exact formatting in complex content with frontmatter', () => {
// Arrange
const complexContent = `# Search Command
## Input
User provided: $ARGUMENTS
## Steps
1. Parse the input: \`$ARGUMENTS\`
2. Search for matches
3. Display results
\`\`\`
Query: $ARGUMENTS
\`\`\``;
const testCommands = [
dynamicCommand(
'search',
'Search the codebase',
'<query>',
complexContent
)
];
// Act
openCodeProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.opencode',
'command',
'tm-search.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toBe(
'---\ndescription: Search the codebase\n---\n' + complexContent
);
// Verify all $ARGUMENTS placeholders are preserved
expect(content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
});
});

View File

@@ -0,0 +1,616 @@
/**
* @fileoverview Integration Tests for RooProfile
* Tests actual filesystem operations using addSlashCommands and removeSlashCommands methods.
*
* RooProfile details:
* - commandsDir: '.roo/commands'
* - extension: '.md'
* - Format: YAML frontmatter with description and optional argument-hint
* - supportsNestedCommands: false (uses tm- prefix for filenames)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { RooProfile } from '../../src/slash-commands/profiles/roo-profile.js';
import {
staticCommand,
dynamicCommand
} from '../../src/slash-commands/factories.js';
describe('RooProfile Integration Tests', () => {
let tempDir: string;
let rooProfile: RooProfile;
beforeEach(() => {
// Create a temporary directory for testing
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roo-profile-test-'));
rooProfile = new RooProfile();
});
afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('addSlashCommands', () => {
it('should create the .roo/commands directory', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: '# Test Content'
})
];
// Act
const result = rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
const commandsDir = path.join(tempDir, '.roo', 'commands');
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
expect(result.success).toBe(true);
});
it('should write files with frontmatter and tm- prefix', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'plain-test',
description: 'Plain test command',
content: '# Original Content\n\nThis should remain unchanged.'
})
];
// Act
rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.roo',
'commands',
'tm-plain-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// Roo uses YAML frontmatter for metadata
expect(content).toBe(
'---\ndescription: Plain test command\n---\n\n# Original Content\n\nThis should remain unchanged.'
);
});
it('should include argument-hint in frontmatter for dynamic commands', () => {
// Arrange
const testCommands = [
dynamicCommand(
'dynamic-test',
'Dynamic test command',
'[task-id]',
'Process task: $ARGUMENTS\n\nThis processes the specified task.'
)
];
// Act
rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.roo',
'commands',
'tm-dynamic-test.md'
);
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
// Roo uses YAML frontmatter with argument-hint
expect(content).toBe(
'---\ndescription: Dynamic test command\nargument-hint: [task-id]\n---\n\nProcess task: $ARGUMENTS\n\nThis processes the specified task.'
);
expect(content).toContain('$ARGUMENTS');
});
it('should return success result with correct count', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'cmd1',
description: 'First command',
content: 'Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'Second command',
content: 'Content 2'
}),
dynamicCommand(
'cmd3',
'Third command',
'[arg]',
'Content 3: $ARGUMENTS'
)
];
// Act
const result = rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(3);
expect(result.directory).toBe(path.join(tempDir, '.roo', 'commands'));
expect(result.files).toHaveLength(3);
expect(result.files).toContain('tm-cmd1.md');
expect(result.files).toContain('tm-cmd2.md');
expect(result.files).toContain('tm-cmd3.md');
});
it('should overwrite existing files on re-run', () => {
// Arrange
const initialCommands = [
staticCommand({
name: 'test-cmd',
description: 'Initial description',
content: 'Initial content'
})
];
// Act - First run
rooProfile.addSlashCommands(tempDir, initialCommands);
const filePath = path.join(tempDir, '.roo', 'commands', 'tm-test-cmd.md');
const initialContent = fs.readFileSync(filePath, 'utf-8');
expect(initialContent).toBe(
'---\ndescription: Initial description\n---\n\nInitial content'
);
// Act - Re-run with updated command
const updatedCommands = [
staticCommand({
name: 'test-cmd',
description: 'Updated description',
content: 'Updated content'
})
];
rooProfile.addSlashCommands(tempDir, updatedCommands);
// Assert
const updatedContent = fs.readFileSync(filePath, 'utf-8');
expect(updatedContent).toBe(
'---\ndescription: Updated description\n---\n\nUpdated content'
);
expect(updatedContent).not.toContain('Initial');
});
it('should handle multiple commands with mixed types', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'static1',
description: 'Static command 1',
content: 'Static content 1'
}),
dynamicCommand(
'dynamic1',
'Dynamic command 1',
'[id]',
'Dynamic content $ARGUMENTS'
),
staticCommand({
name: 'static2',
description: 'Static command 2',
content: 'Static content 2'
})
];
// Act
const result = rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(3);
// Verify all files exist (with tm- prefix)
const static1Path = path.join(
tempDir,
'.roo',
'commands',
'tm-static1.md'
);
const dynamic1Path = path.join(
tempDir,
'.roo',
'commands',
'tm-dynamic1.md'
);
const static2Path = path.join(
tempDir,
'.roo',
'commands',
'tm-static2.md'
);
expect(fs.existsSync(static1Path)).toBe(true);
expect(fs.existsSync(dynamic1Path)).toBe(true);
expect(fs.existsSync(static2Path)).toBe(true);
// Verify content includes frontmatter
const static1Content = fs.readFileSync(static1Path, 'utf-8');
expect(static1Content).toBe(
'---\ndescription: Static command 1\n---\n\nStatic content 1'
);
const dynamic1Content = fs.readFileSync(dynamic1Path, 'utf-8');
expect(dynamic1Content).toBe(
'---\ndescription: Dynamic command 1\nargument-hint: [id]\n---\n\nDynamic content $ARGUMENTS'
);
});
it('should handle empty command list', () => {
// Act
const result = rooProfile.addSlashCommands(tempDir, []);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
it('should preserve multiline content with frontmatter', () => {
// Arrange
const multilineContent = `# Task Runner
## Description
Run automated tasks for the project.
## Steps
1. Check dependencies
2. Run build
3. Execute tests
4. Generate report`;
const testCommands = [
staticCommand({
name: 'task-runner',
description: 'Run automated tasks',
content: multilineContent
})
];
// Act
rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(
tempDir,
'.roo',
'commands',
'tm-task-runner.md'
);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toBe(
'---\ndescription: Run automated tasks\n---\n\n' + multilineContent
);
});
it('should preserve code blocks and special characters in content with frontmatter', () => {
// Arrange
const contentWithCode = `# Deploy
Run the deployment:
\`\`\`bash
npm run deploy
\`\`\`
Use \`$HOME\` and \`$PATH\` variables. Also: <tag> & "quotes"`;
const testCommands = [
staticCommand({
name: 'deploy',
description: 'Deploy the application',
content: contentWithCode
})
];
// Act
rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(tempDir, '.roo', 'commands', 'tm-deploy.md');
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toBe(
'---\ndescription: Deploy the application\n---\n\n' + contentWithCode
);
expect(content).toContain('```bash');
expect(content).toContain('$HOME');
expect(content).toContain('<tag> & "quotes"');
});
});
describe('removeSlashCommands', () => {
it('should remove only TaskMaster commands and preserve user files', () => {
// Arrange - Add TaskMaster commands
const tmCommands = [
staticCommand({
name: 'cmd1',
description: 'TaskMaster command 1',
content: 'TM Content 1'
}),
staticCommand({
name: 'cmd2',
description: 'TaskMaster command 2',
content: 'TM Content 2'
})
];
rooProfile.addSlashCommands(tempDir, tmCommands);
// Create a user file manually
const commandsDir = path.join(tempDir, '.roo', 'commands');
const userFilePath = path.join(commandsDir, 'user-custom.md');
fs.writeFileSync(userFilePath, 'User custom command\n\nUser content');
// Act - Remove TaskMaster commands
const result = rooProfile.removeSlashCommands(tempDir, tmCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(result.files).toHaveLength(2);
// Verify TaskMaster files are removed (tm- prefix is added automatically)
expect(fs.existsSync(path.join(commandsDir, 'tm-cmd1.md'))).toBe(false);
expect(fs.existsSync(path.join(commandsDir, 'tm-cmd2.md'))).toBe(false);
// Verify user file is preserved
expect(fs.existsSync(userFilePath)).toBe(true);
const userContent = fs.readFileSync(userFilePath, 'utf-8');
expect(userContent).toContain('User custom command');
});
it('should remove empty directory after cleanup', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'only-cmd',
description: 'Only command',
content: 'Only content'
})
];
rooProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.roo', 'commands');
expect(fs.existsSync(commandsDir)).toBe(true);
// Act - Remove all TaskMaster commands
const result = rooProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should be removed when empty (default behavior)
expect(fs.existsSync(commandsDir)).toBe(false);
});
it('should keep directory when user files remain', () => {
// Arrange
const tmCommands = [
staticCommand({
name: 'cmd',
description: 'TaskMaster command',
content: 'TM Content'
})
];
rooProfile.addSlashCommands(tempDir, tmCommands);
// Add user file
const commandsDir = path.join(tempDir, '.roo', 'commands');
const userFilePath = path.join(commandsDir, 'my-command.md');
fs.writeFileSync(userFilePath, 'My custom command');
// Act - Remove TaskMaster commands
const result = rooProfile.removeSlashCommands(tempDir, tmCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist because user file remains
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.existsSync(userFilePath)).toBe(true);
});
it('should handle removal when no files exist', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'nonexistent',
description: 'Non-existent command',
content: 'Content'
})
];
// Act - Don't add commands, just try to remove
const result = rooProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
it('should handle removal when directory does not exist', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: 'Content'
})
];
// Ensure .roo/commands doesn't exist
const commandsDir = path.join(tempDir, '.roo', 'commands');
expect(fs.existsSync(commandsDir)).toBe(false);
// Act
const result = rooProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should remove mixed command types', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'static-cmd',
description: 'Static command',
content: 'Static content'
}),
dynamicCommand(
'dynamic-cmd',
'Dynamic command',
'[arg]',
'Dynamic content $ARGUMENTS'
)
];
rooProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.roo', 'commands');
expect(fs.existsSync(path.join(commandsDir, 'tm-static-cmd.md'))).toBe(
true
);
expect(fs.existsSync(path.join(commandsDir, 'tm-dynamic-cmd.md'))).toBe(
true
);
// Act
const result = rooProfile.removeSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(fs.existsSync(path.join(commandsDir, 'tm-static-cmd.md'))).toBe(
false
);
expect(fs.existsSync(path.join(commandsDir, 'tm-dynamic-cmd.md'))).toBe(
false
);
// Directory should be removed since it's empty
expect(fs.existsSync(commandsDir)).toBe(false);
});
it('should not remove directory when removeEmptyDir is false', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd',
description: 'Test command',
content: 'Content'
})
];
rooProfile.addSlashCommands(tempDir, testCommands);
const commandsDir = path.join(tempDir, '.roo', 'commands');
expect(fs.existsSync(commandsDir)).toBe(true);
// Act - Remove with removeEmptyDir=false
const result = rooProfile.removeSlashCommands(
tempDir,
testCommands,
false
);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(1);
// Directory should still exist even though it's empty
expect(fs.existsSync(commandsDir)).toBe(true);
});
});
describe('edge cases', () => {
it('should handle commands with special characters in names', () => {
// Arrange
const testCommands = [
staticCommand({
name: 'test-cmd-123',
description: 'Test with numbers',
content: 'Content'
}),
staticCommand({
name: 'test_underscore',
description: 'Test with underscore',
content: 'Content'
})
];
// Act
const result = rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(2);
const commandsDir = path.join(tempDir, '.roo', 'commands');
expect(fs.existsSync(path.join(commandsDir, 'tm-test-cmd-123.md'))).toBe(
true
);
expect(
fs.existsSync(path.join(commandsDir, 'tm-test_underscore.md'))
).toBe(true);
});
it('should preserve exact formatting in complex content with frontmatter', () => {
// Arrange
const complexContent = `# Search Command
## Input
User provided: $ARGUMENTS
## Steps
1. Parse the input: \`$ARGUMENTS\`
2. Search for matches
3. Display results
\`\`\`
Query: $ARGUMENTS
\`\`\``;
const testCommands = [
dynamicCommand(
'search',
'Search the codebase',
'<query>',
complexContent
)
];
// Act
rooProfile.addSlashCommands(tempDir, testCommands);
// Assert
const filePath = path.join(tempDir, '.roo', 'commands', 'tm-search.md');
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toBe(
'---\ndescription: Search the codebase\nargument-hint: <query>\n---\n\n' +
complexContent
);
// Verify all $ARGUMENTS placeholders are preserved
expect(content.match(/\$ARGUMENTS/g)?.length).toBe(3);
});
});
});

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": ".",
"rootDir": ".",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"types": ["node", "vitest/globals"],
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config';
/**
* Package-specific Vitest configuration for @tm/profiles
* Only tests the profile classes, not the command definitions
*/
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts', 'src/**/*.test.ts', 'tests/**/*.test.ts'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
enabled: true,
reporter: ['text'],
// Only measure coverage for profile classes
exclude: ['node_modules', 'dist', 'src/slash-commands/commands/**'],
thresholds: {
branches: 70,
functions: 80,
lines: 80,
statements: 80
}
}
}
});

View File

@@ -716,12 +716,21 @@ function updateStorageConfig(configPath, selectedStorage, authCredentials) {
process.env.TM_PUBLIC_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN ||
'https://tryhamster.com/api'; 'https://tryhamster.com/api';
// Set operating mode to 'team' for cloud storage (Hamster)
// This determines which slash commands and rules are installed
config.storage.operatingMode = 'team';
// Note: Access token is stored in ~/.taskmaster/auth.json by AuthManager // Note: Access token is stored in ~/.taskmaster/auth.json by AuthManager
// We don't store it in config.json for security reasons // We don't store it in config.json for security reasons
log('debug', 'Connected to Hamster Studio'); log('debug', 'Connected to Hamster Studio');
} else { } else {
// Configure for local file storage // Configure for local file storage
config.storage.type = 'file'; config.storage.type = 'file';
// Set operating mode to 'solo' for local storage (Taskmaster standalone)
// This determines which slash commands and rules are installed
config.storage.operatingMode = 'solo';
log('debug', 'Configured storage for local file storage'); log('debug', 'Configured storage for local file storage');
} }
@@ -843,10 +852,18 @@ async function createProjectStructure(
}; };
// Helper function to create rule profiles // Helper function to create rule profiles
// Derives operating mode from storage selection:
// - 'cloud' (Hamster) -> 'team' mode
// - 'local' (Taskmaster standalone) -> 'solo' mode
const operatingMode = selectedStorage === 'cloud' ? 'team' : 'solo';
function _processSingleProfile(profileName) { function _processSingleProfile(profileName) {
const profile = getRulesProfile(profileName); const profile = getRulesProfile(profileName);
if (profile) { if (profile) {
convertAllRulesToProfileRules(targetDir, profile); // Pass operating mode to filter rules and slash commands
convertAllRulesToProfileRules(targetDir, profile, {
mode: operatingMode
});
// Also triggers MCP config setup (if applicable) // Also triggers MCP config setup (if applicable)
} else { } else {
log('warn', `Unknown rule profile: ${profileName}`); log('warn', `Unknown rule profile: ${profileName}`);

View File

@@ -78,6 +78,7 @@ import {
getConfig, getConfig,
getDebugFlag, getDebugFlag,
getDefaultNumTasks, getDefaultNumTasks,
getOperatingMode,
isApiKeySet, isApiKeySet,
isConfigFilePresent, isConfigFilePresent,
setSuppressConfigWarnings setSuppressConfigWarnings
@@ -4388,11 +4389,16 @@ Examples:
`--${RULES_SETUP_ACTION}`, `--${RULES_SETUP_ACTION}`,
'Run interactive setup to select rule profiles to add' 'Run interactive setup to select rule profiles to add'
) )
.option(
'-m, --mode <mode>',
'Operating mode for filtering rules/commands (solo or team). Auto-detected from config if not specified.'
)
.addHelpText( .addHelpText(
'after', 'after',
` `
Examples: Examples:
$ task-master rules ${RULES_ACTIONS.ADD} windsurf roo # Add Windsurf and Roo rule sets $ task-master rules ${RULES_ACTIONS.ADD} windsurf roo # Add Windsurf and Roo rule sets
$ task-master rules ${RULES_ACTIONS.ADD} cursor --mode=team # Add Cursor rules for team mode only
$ task-master rules ${RULES_ACTIONS.REMOVE} windsurf # Remove Windsurf rule set $ task-master rules ${RULES_ACTIONS.REMOVE} windsurf # Remove Windsurf rule set
$ task-master rules --${RULES_SETUP_ACTION} # Interactive setup to select rule profiles` $ task-master rules --${RULES_SETUP_ACTION} # Interactive setup to select rule profiles`
) )
@@ -4447,10 +4453,11 @@ Examples:
continue; continue;
} }
const profileConfig = getRulesProfile(profile); const profileConfig = getRulesProfile(profile);
const mode = await getOperatingMode(options.mode);
const addResult = convertAllRulesToProfileRules( const addResult = convertAllRulesToProfileRules(
projectRoot, projectRoot,
profileConfig profileConfig,
{ mode }
); );
console.log(chalk.green(generateProfileSummary(profile, addResult))); console.log(chalk.green(generateProfileSummary(profile, addResult)));
@@ -4525,9 +4532,11 @@ Examples:
if (action === RULES_ACTIONS.ADD) { if (action === RULES_ACTIONS.ADD) {
console.log(chalk.blue(`Adding rules for profile: ${profile}...`)); console.log(chalk.blue(`Adding rules for profile: ${profile}...`));
const mode = await getOperatingMode(options.mode);
const addResult = convertAllRulesToProfileRules( const addResult = convertAllRulesToProfileRules(
projectRoot, projectRoot,
profileConfig profileConfig,
{ mode }
); );
console.log( console.log(
chalk.blue(`Completed adding rules for profile: ${profile}`) chalk.blue(`Completed adding rules for profile: ${profile}`)

View File

@@ -3,6 +3,7 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { import {
ALL_PROVIDERS, ALL_PROVIDERS,
AuthManager,
CUSTOM_PROVIDERS, CUSTOM_PROVIDERS,
CUSTOM_PROVIDERS_ARRAY, CUSTOM_PROVIDERS_ARRAY,
VALIDATED_PROVIDERS VALIDATED_PROVIDERS
@@ -1189,6 +1190,51 @@ function getBaseUrlForRole(role, explicitRoot = null) {
return undefined; return undefined;
} }
/**
* Get the operating mode for rules/commands filtering.
* Priority order:
* 1. Explicit CLI flag (--mode=solo|team)
* 2. Config file (storage.operatingMode)
* 3. Auth status fallback (authenticated = team, else solo)
*
* @param {string|undefined} explicitMode - Mode passed via CLI flag
* @returns {Promise<'solo'|'team'>} The operating mode
*/
async function getOperatingMode(explicitMode) {
// 1. CLI flag takes precedence
if (explicitMode === 'solo' || explicitMode === 'team') {
return explicitMode;
}
// 2. Check config file for operatingMode
try {
setSuppressConfigWarnings(true);
const config = getConfig(null, false, { storageType: 'api' });
if (config?.storage?.operatingMode) {
return config.storage.operatingMode;
}
} catch {
// Config check failed, continue to fallback
} finally {
setSuppressConfigWarnings(false);
}
// 3. Fallback: Check auth status
// If authenticated with Hamster, assume team mode
try {
const authManager = AuthManager.getInstance();
const credentials = await authManager.getAuthCredentials();
if (credentials) {
return 'team';
}
} catch {
// Auth check failed, default to solo
}
// Default to solo mode
return 'solo';
}
// Export the providers without API keys array for use in other modules // Export the providers without API keys array for use in other modules
export const providersWithoutApiKeys = [ export const providersWithoutApiKeys = [
CUSTOM_PROVIDERS.OLLAMA, CUSTOM_PROVIDERS.OLLAMA,
@@ -1257,6 +1303,8 @@ export {
getAnonymousTelemetryEnabled, getAnonymousTelemetryEnabled,
getParametersForRole, getParametersForRole,
getUserId, getUserId,
// Operating mode
getOperatingMode,
// API Key Checkers (still relevant) // API Key Checkers (still relevant)
isApiKeySet, isApiKeySet,
getMcpApiKeyStatus, getMcpApiKeyStatus,

View File

@@ -1,5 +1,52 @@
// Base profile factory for rule-transformer // Base profile factory for rule-transformer
import path from 'path'; import path from 'path';
import { getProfile, allCommands } from '@tm/profiles';
import { log } from '../../scripts/modules/utils.js';
/**
* Rule files categorized by operating mode.
* - Solo: Rules for local file-based storage (Taskmaster standalone)
* - Team: Rules for API/cloud storage (Hamster integration)
*
* Team mode is EXCLUSIVE - team users get ONLY team-specific rules.
*/
export const RULE_MODES = {
/** Solo-only rules (local file storage) */
solo: [
'rules/taskmaster.mdc',
'rules/dev_workflow.mdc',
'rules/self_improve.mdc',
'rules/cursor_rules.mdc',
'rules/taskmaster_hooks_workflow.mdc'
],
/** Team-only rules (API/cloud storage - exclusive) */
team: ['rules/hamster.mdc']
};
/**
* Filter rules by operating mode.
* Team mode is EXCLUSIVE - returns ONLY team-specific rules.
*
* @param {Object} fileMap - The rule fileMap object
* @param {'solo' | 'team'} mode - Operating mode
* @returns {Object} - Filtered fileMap
*/
export function filterRulesByMode(fileMap, mode) {
if (mode === 'team') {
// Team mode: ONLY team-specific rules (exclusive)
return Object.fromEntries(
Object.entries(fileMap).filter(([sourceFile]) =>
RULE_MODES.team.includes(sourceFile)
)
);
}
// Solo mode: solo-specific rules only (no team rules)
return Object.fromEntries(
Object.entries(fileMap).filter(([sourceFile]) =>
RULE_MODES.solo.includes(sourceFile)
)
);
}
/** /**
* Creates a standardized profile configuration for different editors * Creates a standardized profile configuration for different editors
@@ -221,6 +268,23 @@ export function createProfile(editorConfig) {
: sourceFilename; : sourceFilename;
} }
// Auto-detect slash command support from @tm/profiles
let slashCommands = null;
try {
const slashCommandProfile = getProfile(name);
if (slashCommandProfile?.supportsCommands) {
slashCommands = {
profile: slashCommandProfile,
commands: allCommands
};
}
} catch (err) {
log(
'debug',
`[${displayName}] Slash command profile lookup failed: ${err.message}`
);
}
return { return {
profileName: name, // Use name for programmatic access (tests expect this) profileName: name, // Use name for programmatic access (tests expect this)
displayName: displayName, // Keep displayName for UI purposes displayName: displayName, // Keep displayName for UI purposes
@@ -236,6 +300,8 @@ export function createProfile(editorConfig) {
conversionConfig, conversionConfig,
getTargetRuleFilename, getTargetRuleFilename,
targetExtension, targetExtension,
// Declarative slash command config - rule-transformer handles execution
slashCommands,
// Optional lifecycle hooks // Optional lifecycle hooks
...(onAdd && { onAddRulesProfile: onAdd }), ...(onAdd && { onAddRulesProfile: onAdd }),
...(onRemove && { onRemoveRulesProfile: onRemove }), ...(onRemove && { onRemoveRulesProfile: onRemove }),

View File

@@ -1,135 +1,6 @@
import fs from 'fs';
// Cursor conversion profile for rule-transformer // Cursor conversion profile for rule-transformer
import path from 'path';
import { log } from '../../scripts/modules/utils.js';
import { createProfile } from './base-profile.js'; import { createProfile } from './base-profile.js';
// Helper copy; use cpSync when available, fallback to manual recursion
function copyRecursiveSync(src, dest) {
if (fs.cpSync) {
try {
fs.cpSync(src, dest, { recursive: true, force: true });
return;
} catch (err) {
throw new Error(`Failed to copy ${src} to ${dest}: ${err.message}`);
}
}
const exists = fs.existsSync(src);
let stats = null;
let isDirectory = false;
if (exists) {
try {
stats = fs.statSync(src);
isDirectory = stats.isDirectory();
} catch (err) {
// Handle TOCTOU race condition - treat as non-existent/not-a-directory
isDirectory = false;
}
}
if (isDirectory) {
try {
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
for (const child of fs.readdirSync(src)) {
copyRecursiveSync(path.join(src, child), path.join(dest, child));
}
} catch (err) {
throw new Error(
`Failed to copy directory ${src} to ${dest}: ${err.message}`
);
}
} else {
try {
// ensure parent exists for file copies
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
} catch (err) {
throw new Error(`Failed to copy file ${src} to ${dest}: ${err.message}`);
}
}
}
// Helper function to recursively remove directory
function removeDirectoryRecursive(dirPath) {
if (fs.existsSync(dirPath)) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
return true;
} catch (err) {
log('error', `Failed to remove directory ${dirPath}: ${err.message}`);
return false;
}
}
return true;
}
// Resolve the Cursor profile directory from either project root, profile root, or rules dir
function resolveCursorProfileDir(baseDir) {
const base = path.basename(baseDir);
// If called with .../.cursor/rules -> return .../.cursor
if (base === 'rules' && path.basename(path.dirname(baseDir)) === '.cursor') {
return path.dirname(baseDir);
}
// If called with .../.cursor -> return as-is
if (base === '.cursor') return baseDir;
// Otherwise assume project root and append .cursor
return path.join(baseDir, '.cursor');
}
// Lifecycle functions for Cursor profile
function onAddRulesProfile(targetDir, assetsDir) {
// Copy commands directory recursively
const commandsSourceDir = path.join(assetsDir, 'claude', 'commands');
const profileDir = resolveCursorProfileDir(targetDir);
const commandsDestDir = path.join(profileDir, 'commands');
if (!fs.existsSync(commandsSourceDir)) {
log(
'warn',
`[Cursor] Source commands directory does not exist: ${commandsSourceDir}`
);
return;
}
try {
// Ensure fresh state to avoid stale command files
try {
fs.rmSync(commandsDestDir, { recursive: true, force: true });
log(
'debug',
`[Cursor] Removed existing commands directory: ${commandsDestDir}`
);
} catch (deleteErr) {
// Directory might not exist, which is fine
log(
'debug',
`[Cursor] Commands directory did not exist or could not be removed: ${deleteErr.message}`
);
}
copyRecursiveSync(commandsSourceDir, commandsDestDir);
log('debug', `[Cursor] Copied commands directory to ${commandsDestDir}`);
} catch (err) {
log(
'error',
`[Cursor] An error occurred during commands copy: ${err.message}`
);
}
}
function onRemoveRulesProfile(targetDir) {
// Remove .cursor/commands directory recursively
const profileDir = resolveCursorProfileDir(targetDir);
const commandsDir = path.join(profileDir, 'commands');
if (removeDirectoryRecursive(commandsDir)) {
log(
'debug',
`[Cursor] Ensured commands directory removed at ${commandsDir}`
);
}
}
// Create and export cursor profile using the base factory // Create and export cursor profile using the base factory
export const cursorProfile = createProfile({ export const cursorProfile = createProfile({
name: 'cursor', name: 'cursor',
@@ -137,10 +8,5 @@ export const cursorProfile = createProfile({
url: 'cursor.so', url: 'cursor.so',
docsUrl: 'docs.cursor.com', docsUrl: 'docs.cursor.com',
targetExtension: '.mdc', // Cursor keeps .mdc extension targetExtension: '.mdc', // Cursor keeps .mdc extension
supportsRulesSubdirectories: true, supportsRulesSubdirectories: true
onAdd: onAddRulesProfile,
onRemove: onRemoveRulesProfile
}); });
// Export lifecycle functions separately to avoid naming conflicts
export { onAddRulesProfile, onRemoveRulesProfile };

View File

@@ -24,6 +24,9 @@ import { RULE_PROFILES } from '../constants/profiles.js';
// --- Profile Imports --- // --- Profile Imports ---
import * as profilesModule from '../profiles/index.js'; import * as profilesModule from '../profiles/index.js';
// Import rule filtering
import { filterRulesByMode } from '../profiles/base-profile.js';
export function isValidProfile(profile) { export function isValidProfile(profile) {
return RULE_PROFILES.includes(profile); return RULE_PROFILES.includes(profile);
} }
@@ -198,14 +201,66 @@ export function convertRuleToProfileRule(sourcePath, targetPath, profile) {
} }
/** /**
* Convert all Cursor rules to profile rules for a specific profile * Options for converting rules to profile rules
* @typedef {Object} ConvertRulesOptions
* @property {'solo' | 'team'} [mode] - Operating mode to filter rules
*/ */
export function convertAllRulesToProfileRules(projectRoot, profile) {
/**
* Remove all TaskMaster rule files from a profile (used when switching modes)
* This removes files from ALL modes to ensure a clean slate.
* @param {string} projectRoot - The project root directory
* @param {Object} profile - The profile configuration
*/
function removeTaskMasterRuleFiles(projectRoot, profile) {
const targetDir = path.join(projectRoot, profile.rulesDir); const targetDir = path.join(projectRoot, profile.rulesDir);
if (!fs.existsSync(targetDir)) {
return; // Nothing to remove
}
// Get all TaskMaster rule files (from all modes)
const allRuleFiles = Object.values(profile.fileMap);
for (const ruleFile of allRuleFiles) {
const filePath = path.join(targetDir, ruleFile);
if (fs.existsSync(filePath)) {
try {
fs.rmSync(filePath, { force: true });
log('debug', `[Rule Transformer] Removed rule file: ${ruleFile}`);
} catch (error) {
log(
'warn',
`[Rule Transformer] Failed to remove rule file ${ruleFile}: ${error.message}`
);
}
}
}
}
/**
* Convert all Cursor rules to profile rules for a specific profile
* @param {string} projectRoot - The project root directory
* @param {Object} profile - The profile configuration
* @param {ConvertRulesOptions} [options] - Options including mode filtering
*/
export function convertAllRulesToProfileRules(projectRoot, profile, options) {
const targetDir = path.join(projectRoot, profile.rulesDir);
const mode = options?.mode;
let success = 0; let success = 0;
let failed = 0; let failed = 0;
// 0. When mode is specified, first remove ALL existing TaskMaster rules
// to ensure clean slate (prevents orphaned rules when switching modes)
if (mode) {
removeTaskMasterRuleFiles(projectRoot, profile);
log(
'debug',
`[Rule Transformer] Cleaned up existing rules before adding ${mode} mode rules`
);
}
// 1. Call onAddRulesProfile first (for pre-processing like copying assets) // 1. Call onAddRulesProfile first (for pre-processing like copying assets)
if (typeof profile.onAddRulesProfile === 'function') { if (typeof profile.onAddRulesProfile === 'function') {
try { try {
@@ -225,7 +280,11 @@ export function convertAllRulesToProfileRules(projectRoot, profile) {
} }
// 2. Handle fileMap-based rule conversion (if any) // 2. Handle fileMap-based rule conversion (if any)
const sourceFiles = Object.keys(profile.fileMap); // Filter by mode if specified
const filteredFileMap = mode
? filterRulesByMode(profile.fileMap, mode)
: profile.fileMap;
const sourceFiles = Object.keys(filteredFileMap);
if (sourceFiles.length > 0) { if (sourceFiles.length > 0) {
// Only create rules directory if we have files to copy // Only create rules directory if we have files to copy
if (!fs.existsSync(targetDir)) { if (!fs.existsSync(targetDir)) {
@@ -246,7 +305,7 @@ export function convertAllRulesToProfileRules(projectRoot, profile) {
continue; continue;
} }
const targetFilename = profile.fileMap[sourceFile]; const targetFilename = filteredFileMap[sourceFile];
const targetPath = path.join(targetDir, targetFilename); const targetPath = path.join(targetDir, targetFilename);
// Ensure target subdirectory exists (for rules like taskmaster/dev_workflow.md) // Ensure target subdirectory exists (for rules like taskmaster/dev_workflow.md)
@@ -318,6 +377,35 @@ export function convertAllRulesToProfileRules(projectRoot, profile) {
} }
} }
// 5. Add slash commands (if profile supports them)
if (profile.slashCommands) {
try {
// Pass mode option for filtering commands by operating mode
const slashCommandOptions = mode ? { mode } : undefined;
const result = profile.slashCommands.profile.addSlashCommands(
projectRoot,
profile.slashCommands.commands,
slashCommandOptions
);
if (result.success) {
log(
'debug',
`[Rule Transformer] Created ${result.count} slash commands in ${result.directory}${mode ? ` (mode: ${mode})` : ''}`
);
} else {
log(
'error',
`[Rule Transformer] Failed to add slash commands for ${profile.profileName}: ${result.error}`
);
}
} catch (error) {
log(
'error',
`[Rule Transformer] Slash commands failed for ${profile.profileName}: ${error.message}`
);
}
}
// Ensure we return at least 1 success for profiles that only use lifecycle functions // Ensure we return at least 1 success for profiles that only use lifecycle functions
return { success: Math.max(success, 1), failed }; return { success: Math.max(success, 1), failed };
} }
@@ -493,7 +581,33 @@ export function removeProfileRules(projectRoot, profile) {
} }
} }
// 4. Check if we should remove the entire profile directory // 4. Remove slash commands (if profile supports them)
if (profile.slashCommands) {
try {
const slashResult = profile.slashCommands.profile.removeSlashCommands(
projectRoot,
profile.slashCommands.commands
);
if (slashResult.success && slashResult.count > 0) {
log(
'debug',
`[Rule Transformer] Removed ${slashResult.count} slash commands for ${profile.profileName}`
);
} else if (!slashResult.success) {
log(
'error',
`[Rule Transformer] Failed to remove slash commands for ${profile.profileName}: ${slashResult.error}`
);
}
} catch (error) {
log(
'error',
`[Rule Transformer] Slash command cleanup failed for ${profile.profileName}: ${error.message}`
);
}
}
// 5. Check if we should remove the entire profile directory
if (fs.existsSync(profileDir)) { if (fs.existsSync(profileDir)) {
const remainingContents = fs.readdirSync(profileDir); const remainingContents = fs.readdirSync(profileDir);
if (remainingContents.length === 0 && profile.profileDir !== '.') { if (remainingContents.length === 0 && profile.profileDir !== '.') {

View File

@@ -55,32 +55,28 @@ describe('Cursor Profile Initialization Functionality', () => {
expect(cursorProfile.conversionConfig.toolNames.search).toBe('search'); expect(cursorProfile.conversionConfig.toolNames.search).toBe('search');
}); });
test('cursor.js has lifecycle functions for command copying', () => { test('cursor.js uses factory pattern from base-profile', () => {
// Check that the source file contains our new lifecycle functions // The new architecture uses createProfile from base-profile.js
expect(cursorProfileContent).toContain('function onAddRulesProfile'); // which auto-generates lifecycle hooks via @tm/profiles package
expect(cursorProfileContent).toContain('function onRemoveRulesProfile'); expect(cursorProfileContent).toContain('import { createProfile }');
expect(cursorProfileContent).toContain('copyRecursiveSync'); expect(cursorProfileContent).toContain('createProfile(');
expect(cursorProfileContent).toContain('removeDirectoryRecursive');
// Verify supportsRulesSubdirectories is enabled for cursor (it uses taskmaster/ subdirectory)
expect(cursorProfileContent).toContain('supportsRulesSubdirectories: true');
}); });
test('cursor.js copies commands from claude/commands to .cursor/commands', () => { test('cursor profile has declarative slashCommands property via @tm/profiles', () => {
// Check that the onAddRulesProfile function copies from the correct source // The new architecture uses a declarative slashCommands property
expect(cursorProfileContent).toContain( // instead of lifecycle hooks - rule-transformer handles execution
"path.join(assetsDir, 'claude', 'commands')" // slashCommands will be defined if @tm/profiles returns a profile that supports commands
); // In test environment, this may be undefined if @tm/profiles isn't fully loaded
// Destination path is built via a resolver to handle both project root and rules dir if (cursorProfile.slashCommands) {
expect(cursorProfileContent).toContain('resolveCursorProfileDir('); expect(cursorProfile.slashCommands.profile).toBeDefined();
expect(cursorProfileContent).toMatch( expect(cursorProfile.slashCommands.commands).toBeDefined();
/path\.join\(\s*profileDir\s*,\s*['"]commands['"]\s*\)/ }
); // The cursor profile should NOT have explicit lifecycle hooks
expect(cursorProfileContent).toContain( // (it uses the declarative slashCommands approach)
'copyRecursiveSync(commandsSourceDir, commandsDestDir)' expect(cursorProfileContent).not.toContain('onAdd:');
); expect(cursorProfileContent).not.toContain('onRemove:');
// Check that lifecycle functions are properly registered with the profile
expect(cursorProfile.onAddRulesProfile).toBeDefined();
expect(cursorProfile.onRemoveRulesProfile).toBeDefined();
expect(typeof cursorProfile.onAddRulesProfile).toBe('function');
expect(typeof cursorProfile.onRemoveRulesProfile).toBe('function');
}); });
}); });

View File

@@ -9,7 +9,8 @@ import * as profilesModule from '../../../src/profiles/index.js';
* Integration tests for hamster rules distribution across all profiles. * Integration tests for hamster rules distribution across all profiles.
* *
* These tests verify that hamster.mdc is correctly distributed * These tests verify that hamster.mdc is correctly distributed
* to all profiles that include default rules when running `rules add`. * to all profiles that include default rules when running `rules add --mode=team`.
* Note: hamster.mdc is team-mode only (for Hamster API integration).
*/ */
describe('Hamster Rules Distribution', () => { describe('Hamster Rules Distribution', () => {
const CLI_PATH = path.join(process.cwd(), 'dist', 'task-master.js'); const CLI_PATH = path.join(process.cwd(), 'dist', 'task-master.js');
@@ -69,18 +70,18 @@ describe('Hamster Rules Distribution', () => {
}); });
}); });
describe('Rules add command distributes hamster file', () => { describe('Rules add command distributes hamster file in team mode', () => {
// Test each profile that should receive hamster rules // Test each profile that should receive hamster rules when --mode=team
PROFILES_WITH_DEFAULT_RULES.forEach((profile) => { PROFILES_WITH_DEFAULT_RULES.forEach((profile) => {
test(`${profile} profile receives hamster rules via 'rules add'`, () => { test(`${profile} profile receives hamster rules via 'rules add --mode=team'`, () => {
// Create a unique temp directory for this test // Create a unique temp directory for this test
const tempDir = fs.mkdtempSync( const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), `tm-hamster-test-${profile}-`) path.join(os.tmpdir(), `tm-hamster-test-${profile}-`)
); );
try { try {
// Run the rules add command // Run the rules add command with team mode (hamster.mdc is team-only)
execSync(`node ${CLI_PATH} rules add ${profile}`, { execSync(`node ${CLI_PATH} rules add ${profile} --mode=team`, {
cwd: tempDir, cwd: tempDir,
stdio: 'pipe', stdio: 'pipe',
env: { ...process.env, TASKMASTER_LOG_LEVEL: 'error' } env: { ...process.env, TASKMASTER_LOG_LEVEL: 'error' }
@@ -108,6 +109,67 @@ describe('Hamster Rules Distribution', () => {
}); });
}); });
describe('Solo mode excludes hamster file', () => {
// Test that hamster.mdc is NOT distributed in solo mode
PROFILES_WITH_DEFAULT_RULES.forEach((profile) => {
test(`${profile} profile does NOT receive hamster rules via 'rules add --mode=solo'`, () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), `tm-hamster-solo-test-${profile}-`)
);
try {
// Run in solo mode - hamster should NOT be distributed
execSync(`node ${CLI_PATH} rules add ${profile} --mode=solo`, {
cwd: tempDir,
stdio: 'pipe',
env: { ...process.env, TASKMASTER_LOG_LEVEL: 'error' }
});
const expectedPath = getExpectedHamsterPath(profile, tempDir);
// Strictly enforce that all profiles with default rules must have hamster mapping
expect(expectedPath).not.toBeNull();
// Verify hamster file does NOT exist in solo mode
expect(fs.existsSync(expectedPath)).toBe(false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
});
describe('Default mode behavior (no --mode flag)', () => {
// When no mode is specified, default is 'solo' (no auth/config present)
// This means hamster.mdc should NOT be distributed by default
PROFILES_WITH_DEFAULT_RULES.forEach((profile) => {
test(`${profile} profile defaults to solo mode (no hamster rules without --mode flag)`, () => {
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), `tm-hamster-default-test-${profile}-`)
);
try {
// Run without mode flag - should default to solo
execSync(`node ${CLI_PATH} rules add ${profile}`, {
cwd: tempDir,
stdio: 'pipe',
env: { ...process.env, TASKMASTER_LOG_LEVEL: 'error' }
});
const expectedPath = getExpectedHamsterPath(profile, tempDir);
// Strictly enforce that all profiles with default rules must have hamster mapping
expect(expectedPath).not.toBeNull();
// Default is solo mode, so hamster file should NOT exist
expect(fs.existsSync(expectedPath)).toBe(false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
});
describe('Profiles without default rules', () => { describe('Profiles without default rules', () => {
// These profiles use different mechanisms (CLAUDE.md, AGENTS.md, etc.) // These profiles use different mechanisms (CLAUDE.md, AGENTS.md, etc.)
// and don't include the defaultFileMap rules // and don't include the defaultFileMap rules

View File

@@ -0,0 +1,394 @@
import { jest } from '@jest/globals';
// Mock console methods to avoid chalk issues
const mockConsole = {
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
clear: jest.fn()
};
const originalConsole = global.console;
global.console = mockConsole;
// Mock the utils logger
const mockLog = jest.fn();
await jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({
default: undefined,
log: mockLog,
isSilentMode: () => false
}));
// Mock @tm/profiles module
const mockGetProfile = jest.fn();
const mockAllCommands = [
{
type: 'static',
metadata: { name: 'help', description: 'Show help' },
content: 'Help content'
},
{
type: 'dynamic',
metadata: {
name: 'show-task',
description: 'Show task',
argumentHint: '[task-id]'
},
content: 'Show task $ARGUMENTS'
}
];
await jest.unstable_mockModule('@tm/profiles', () => ({
getProfile: mockGetProfile,
allCommands: mockAllCommands
}));
// Import createProfile after mocking
const { createProfile } = await import('../../../src/profiles/base-profile.js');
describe('Base Profile - Declarative Slash Commands', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
global.console = originalConsole;
});
describe('slashCommands config when profile supports commands', () => {
it('should include slashCommands config when profile supports slash commands', () => {
// Arrange - Mock a profile that supports commands
const mockSlashProfile = {
supportsCommands: true,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
};
mockGetProfile.mockReturnValue(mockSlashProfile);
// Act
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor',
url: 'cursor.com',
docsUrl: 'docs.cursor.com'
});
// Assert - Profile should have slashCommands config
expect(profile.slashCommands).toBeDefined();
expect(profile.slashCommands.profile).toBe(mockSlashProfile);
expect(profile.slashCommands.commands).toBe(mockAllCommands);
});
it('should include profile methods in slashCommands config', () => {
// Arrange
const mockAddSlashCommands = jest.fn();
const mockRemoveSlashCommands = jest.fn();
mockGetProfile.mockReturnValue({
supportsCommands: true,
addSlashCommands: mockAddSlashCommands,
removeSlashCommands: mockRemoveSlashCommands
});
// Act
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor',
url: 'cursor.com',
docsUrl: 'docs.cursor.com'
});
// Assert - Methods should be accessible
expect(profile.slashCommands.profile.addSlashCommands).toBe(
mockAddSlashCommands
);
expect(profile.slashCommands.profile.removeSlashCommands).toBe(
mockRemoveSlashCommands
);
});
});
describe('No slashCommands config when profile does not support commands', () => {
it('should not include slashCommands when profile does not support slash commands', () => {
// Arrange - Mock a profile that does NOT support commands
mockGetProfile.mockReturnValue({
supportsCommands: false,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
});
// Act
const profile = createProfile({
name: 'amp',
displayName: 'Amp',
url: 'amp.rs',
docsUrl: 'docs.amp.rs'
});
// Assert - Profile should NOT have slashCommands config
expect(profile.slashCommands).toBeNull();
});
it('should not include slashCommands when getProfile returns null', () => {
// Arrange
mockGetProfile.mockReturnValue(null);
// Act
const profile = createProfile({
name: 'unknown',
displayName: 'Unknown Editor',
url: 'example.com',
docsUrl: 'docs.example.com'
});
// Assert
expect(profile.slashCommands).toBeNull();
});
it('should not include slashCommands when getProfile returns undefined', () => {
// Arrange
mockGetProfile.mockReturnValue(undefined);
// Act
const profile = createProfile({
name: 'another-unknown',
displayName: 'Another Unknown',
url: 'example.org',
docsUrl: 'docs.example.org'
});
// Assert
expect(profile.slashCommands).toBeNull();
});
});
describe('User hooks remain independent of slashCommands', () => {
it('should keep user onAdd hook separate from slashCommands', () => {
// Arrange
mockGetProfile.mockReturnValue({
supportsCommands: true,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
});
const userOnAdd = jest.fn();
// Act
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor',
url: 'cursor.com',
docsUrl: 'docs.cursor.com',
onAdd: userOnAdd
});
// Assert - Both should exist independently
expect(profile.slashCommands).toBeDefined();
expect(profile.onAddRulesProfile).toBe(userOnAdd);
});
it('should keep user onRemove hook separate from slashCommands', () => {
// Arrange
mockGetProfile.mockReturnValue({
supportsCommands: true,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
});
const userOnRemove = jest.fn();
// Act
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor',
url: 'cursor.com',
docsUrl: 'docs.cursor.com',
onRemove: userOnRemove
});
// Assert - Both should exist independently
expect(profile.slashCommands).toBeDefined();
expect(profile.onRemoveRulesProfile).toBe(userOnRemove);
});
it('should preserve user hooks when profile does not support commands', () => {
// Arrange
mockGetProfile.mockReturnValue({
supportsCommands: false
});
const userOnAdd = jest.fn();
const userOnRemove = jest.fn();
// Act
const profile = createProfile({
name: 'amp',
displayName: 'Amp',
url: 'amp.rs',
docsUrl: 'docs.amp.rs',
onAdd: userOnAdd,
onRemove: userOnRemove
});
// Assert
expect(profile.slashCommands).toBeNull();
expect(profile.onAddRulesProfile).toBe(userOnAdd);
expect(profile.onRemoveRulesProfile).toBe(userOnRemove);
});
});
describe('Error handling for getProfile', () => {
it('should handle getProfile throwing an error gracefully', () => {
// Arrange
mockGetProfile.mockImplementation(() => {
throw new Error('Module not found');
});
// Act - Should not throw
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor',
url: 'cursor.com',
docsUrl: 'docs.cursor.com'
});
// Assert
expect(profile.slashCommands).toBeNull();
expect(mockLog).toHaveBeenCalledWith(
'debug',
'[Cursor] Slash command profile lookup failed: Module not found'
);
});
it('should preserve user hooks when getProfile throws', () => {
// Arrange
mockGetProfile.mockImplementation(() => {
throw new Error('@tm/profiles not installed');
});
const userOnAdd = jest.fn();
const userOnRemove = jest.fn();
// Act
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor',
url: 'cursor.com',
docsUrl: 'docs.cursor.com',
onAdd: userOnAdd,
onRemove: userOnRemove
});
// Assert
expect(profile.slashCommands).toBeNull();
expect(profile.onAddRulesProfile).toBe(userOnAdd);
expect(profile.onRemoveRulesProfile).toBe(userOnRemove);
expect(mockLog).toHaveBeenCalledWith(
'debug',
'[Cursor] Slash command profile lookup failed: @tm/profiles not installed'
);
});
});
describe('Profile metadata preserved', () => {
it('should preserve all profile metadata alongside slashCommands', () => {
// Arrange
mockGetProfile.mockReturnValue({
supportsCommands: true,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
});
// Act
const profile = createProfile({
name: 'cursor',
displayName: 'Cursor IDE',
url: 'cursor.com',
docsUrl: 'docs.cursor.com',
profileDir: '.cursor',
rulesDir: '.cursor/rules',
mcpConfig: true
});
// Assert - All metadata should be present
expect(profile.profileName).toBe('cursor');
expect(profile.displayName).toBe('Cursor IDE');
expect(profile.profileDir).toBe('.cursor');
expect(profile.rulesDir).toBe('.cursor/rules');
expect(profile.mcpConfig).toBe(true);
expect(profile.slashCommands).toBeDefined();
});
it('should use displayName in error logs', () => {
// Arrange
mockGetProfile.mockImplementation(() => {
throw new Error('Test error');
});
// Act
createProfile({
name: 'cursor',
displayName: 'Cursor IDE Pro',
url: 'cursor.com',
docsUrl: 'docs.cursor.com'
});
// Assert
expect(mockLog).toHaveBeenCalledWith(
'debug',
'[Cursor IDE Pro] Slash command profile lookup failed: Test error'
);
});
});
describe('Integration with different profile types', () => {
it('should work with Roo profile configuration', () => {
// Arrange
const rooSlashProfile = {
supportsCommands: true,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
};
mockGetProfile.mockReturnValue(rooSlashProfile);
// Act
const profile = createProfile({
name: 'roo',
displayName: 'Roo Code',
url: 'roo.codes',
docsUrl: 'docs.roo.codes',
profileDir: '.roo',
rulesDir: '.roo/rules'
});
// Assert
expect(profile.slashCommands).toBeDefined();
expect(profile.slashCommands.profile).toBe(rooSlashProfile);
expect(profile.rulesDir).toBe('.roo/rules');
});
it('should work with OpenCode profile configuration', () => {
// Arrange
const opencodeSlashProfile = {
supportsCommands: true,
addSlashCommands: jest.fn(),
removeSlashCommands: jest.fn()
};
mockGetProfile.mockReturnValue(opencodeSlashProfile);
// Act
const profile = createProfile({
name: 'opencode',
displayName: 'OpenCode',
url: 'opencode.app',
docsUrl: 'docs.opencode.app',
profileDir: '.opencode',
rulesDir: '.opencode/prompts'
});
// Assert
expect(profile.slashCommands).toBeDefined();
expect(profile.slashCommands.profile).toBe(opencodeSlashProfile);
expect(profile.rulesDir).toBe('.opencode/prompts');
});
});
});

View File

@@ -27,10 +27,26 @@ await jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({
isSilentMode: () => false isSilentMode: () => false
})); }));
// Mock @tm/profiles to control slash command behavior in tests
const mockAddSlashCommands = jest.fn();
const mockRemoveSlashCommands = jest.fn();
const mockProfile = {
name: 'cursor',
displayName: 'Cursor',
commandsDir: '.cursor/commands',
supportsCommands: true,
addSlashCommands: mockAddSlashCommands,
removeSlashCommands: mockRemoveSlashCommands
};
await jest.unstable_mockModule('@tm/profiles', () => ({
getProfile: jest.fn(() => mockProfile),
allCommands: [{ metadata: { name: 'help' }, content: 'Help content' }],
resolveProjectRoot: jest.fn((targetDir) => targetDir)
}));
// Import the cursor profile after mocking // Import the cursor profile after mocking
const { cursorProfile, onAddRulesProfile, onRemoveRulesProfile } = await import( const { cursorProfile } = await import('../../../src/profiles/cursor.js');
'../../../src/profiles/cursor.js'
);
describe('Cursor Integration', () => { describe('Cursor Integration', () => {
let tempDir; let tempDir;
@@ -95,126 +111,137 @@ describe('Cursor Integration', () => {
); );
}); });
test('cursor profile has lifecycle functions for command copying', () => { test('cursor profile has declarative slash commands configuration', () => {
// Assert that the profile exports the lifecycle functions // The new architecture uses declarative slashCommands property
expect(typeof onAddRulesProfile).toBe('function'); // instead of lifecycle hooks - rule-transformer handles execution
expect(typeof onRemoveRulesProfile).toBe('function'); expect(cursorProfile.slashCommands).toBeDefined();
expect(cursorProfile.onAddRulesProfile).toBe(onAddRulesProfile); expect(cursorProfile.slashCommands.profile).toBeDefined();
expect(cursorProfile.onRemoveRulesProfile).toBe(onRemoveRulesProfile); expect(cursorProfile.slashCommands.commands).toBeDefined();
expect(cursorProfile.slashCommands.profile.supportsCommands).toBe(true);
}); });
describe('command copying lifecycle', () => { test('cursor profile has correct basic configuration', () => {
let mockAssetsDir; expect(cursorProfile.profileName).toBe('cursor');
expect(cursorProfile.displayName).toBe('Cursor');
expect(cursorProfile.profileDir).toBe('.cursor');
expect(cursorProfile.rulesDir).toBe('.cursor/rules');
expect(cursorProfile.supportsRulesSubdirectories).toBe(true);
});
describe('slash commands via slashCommands property', () => {
let mockTargetDir; let mockTargetDir;
beforeEach(() => { beforeEach(() => {
mockAssetsDir = path.join(tempDir, 'assets');
mockTargetDir = path.join(tempDir, 'target'); mockTargetDir = path.join(tempDir, 'target');
// Reset all mocks // Reset all mocks
jest.clearAllMocks(); jest.clearAllMocks();
mockAddSlashCommands.mockReset();
mockRemoveSlashCommands.mockReset();
// Mock fs methods for the lifecycle functions // Default mock return values
jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { mockAddSlashCommands.mockReturnValue({
const pathStr = filePath.toString(); success: true,
if (pathStr.includes('claude/commands')) { count: 10,
return true; // Mock that source commands exist directory: path.join(mockTargetDir, '.cursor', 'commands', 'tm'),
} files: ['help.md', 'next-task.md']
return false; });
mockRemoveSlashCommands.mockReturnValue({
success: true,
count: 10,
directory: path.join(mockTargetDir, '.cursor', 'commands', 'tm'),
files: ['help.md', 'next-task.md']
}); });
jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {});
jest.spyOn(fs, 'readdirSync').mockImplementation(() => ['tm']);
jest
.spyOn(fs, 'statSync')
.mockImplementation(() => ({ isDirectory: () => true }));
jest.spyOn(fs, 'copyFileSync').mockImplementation(() => {});
jest.spyOn(fs, 'rmSync').mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
test('onAddRulesProfile copies commands from assets to .cursor/commands', () => { test('slashCommands.profile can add slash commands', () => {
// Detect if cpSync exists and set up appropriate spy // The rule-transformer uses slashCommands.profile.addSlashCommands
if (fs.cpSync) { const { profile, commands } = cursorProfile.slashCommands;
const cpSpy = jest.spyOn(fs, 'cpSync').mockImplementation(() => {});
// Act
onAddRulesProfile(mockTargetDir, mockAssetsDir);
// Assert
expect(fs.existsSync).toHaveBeenCalledWith(
path.join(mockAssetsDir, 'claude', 'commands')
);
expect(cpSpy).toHaveBeenCalledWith(
path.join(mockAssetsDir, 'claude', 'commands'),
path.join(mockTargetDir, '.cursor', 'commands'),
expect.objectContaining({ recursive: true, force: true })
);
} else {
// Act
onAddRulesProfile(mockTargetDir, mockAssetsDir);
// Assert
expect(fs.existsSync).toHaveBeenCalledWith(
path.join(mockAssetsDir, 'claude', 'commands')
);
expect(fs.mkdirSync).toHaveBeenCalledWith(
path.join(mockTargetDir, '.cursor', 'commands'),
{ recursive: true }
);
expect(fs.copyFileSync).toHaveBeenCalled();
}
});
test('onAddRulesProfile handles missing source directory gracefully', () => {
// Arrange - mock source directory not existing
jest.spyOn(fs, 'existsSync').mockImplementation(() => false);
// Act // Act
onAddRulesProfile(mockTargetDir, mockAssetsDir); profile.addSlashCommands(mockTargetDir, commands);
// Assert - should not attempt to copy anything // Assert - mock was called
expect(fs.mkdirSync).not.toHaveBeenCalled(); expect(mockAddSlashCommands).toHaveBeenCalledWith(
expect(fs.copyFileSync).not.toHaveBeenCalled(); mockTargetDir,
}); commands
test('onRemoveRulesProfile removes .cursor/commands directory', () => {
// Arrange - mock directory exists
jest.spyOn(fs, 'existsSync').mockImplementation(() => true);
// Act
onRemoveRulesProfile(mockTargetDir);
// Assert
expect(fs.rmSync).toHaveBeenCalledWith(
path.join(mockTargetDir, '.cursor', 'commands'),
{ recursive: true, force: true }
); );
}); });
test('onRemoveRulesProfile handles missing directory gracefully', () => { test('slashCommands.profile handles add errors gracefully', () => {
// Arrange - mock directory doesn't exist // Arrange - mock addSlashCommands failure
jest.spyOn(fs, 'existsSync').mockImplementation(() => false); mockAddSlashCommands.mockReturnValue({
success: false,
// Act count: 0,
onRemoveRulesProfile(mockTargetDir); directory: '',
files: [],
// Assert - should still return true but not attempt removal error: 'Permission denied'
expect(fs.rmSync).not.toHaveBeenCalled();
});
test('onRemoveRulesProfile handles removal errors gracefully', () => {
// Arrange - mock directory exists but removal fails
jest.spyOn(fs, 'existsSync').mockImplementation(() => true);
jest.spyOn(fs, 'rmSync').mockImplementation(() => {
throw new Error('Permission denied');
}); });
// Act & Assert - should not throw const { profile, commands } = cursorProfile.slashCommands;
expect(() => onRemoveRulesProfile(mockTargetDir)).not.toThrow();
// Act - should not throw
const result = profile.addSlashCommands(mockTargetDir, commands);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('Permission denied');
});
test('slashCommands.profile can remove slash commands', () => {
const { profile, commands } = cursorProfile.slashCommands;
// Act
profile.removeSlashCommands(mockTargetDir, commands);
// Assert - mock was called
expect(mockRemoveSlashCommands).toHaveBeenCalledWith(
mockTargetDir,
commands
);
});
test('slashCommands.profile handles remove with missing directory gracefully', () => {
// Arrange - mock removeSlashCommands returns success with 0 count
mockRemoveSlashCommands.mockReturnValue({
success: true,
count: 0,
directory: path.join(mockTargetDir, '.cursor', 'commands', 'tm'),
files: []
});
const { profile, commands } = cursorProfile.slashCommands;
// Act
const result = profile.removeSlashCommands(mockTargetDir, commands);
// Assert
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
test('slashCommands.profile handles remove errors gracefully', () => {
// Arrange - mock removeSlashCommands failure
mockRemoveSlashCommands.mockReturnValue({
success: false,
count: 0,
directory: '',
files: [],
error: 'Permission denied'
});
const { profile, commands } = cursorProfile.slashCommands;
// Act
const result = profile.removeSlashCommands(mockTargetDir, commands);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('Permission denied');
}); });
}); });
}); });

View File

@@ -433,7 +433,13 @@ describe('MCP Configuration Validation', () => {
]; ];
const profilesWithLifecycle = ['amp', 'claude']; const profilesWithLifecycle = ['amp', 'claude'];
const profilesWithPostConvertLifecycle = ['opencode']; const profilesWithPostConvertLifecycle = ['opencode'];
const profilesWithoutLifecycle = ['codex']; // Profiles that use declarative slashCommands (no auto-generated lifecycle hooks)
const profilesWithDeclarativeSlashCommands = [
'codex',
'cursor',
'gemini',
'roo'
];
test.each(allProfiles)( test.each(allProfiles)(
'should have file mappings for %s profile', 'should have file mappings for %s profile',
@@ -466,28 +472,30 @@ describe('MCP Configuration Validation', () => {
(profileName) => { (profileName) => {
const profile = getRulesProfile(profileName); const profile = getRulesProfile(profileName);
expect(profile).toBeDefined(); expect(profile).toBeDefined();
// OpenCode profile has fileMap and post-convert lifecycle functions // OpenCode profile has fileMap and explicit lifecycle functions
// Note: OpenCode has onRemove and onPostConvert, but NOT onAdd
expect(profile.fileMap).toBeDefined(); expect(profile.fileMap).toBeDefined();
expect(typeof profile.fileMap).toBe('object'); expect(typeof profile.fileMap).toBe('object');
expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0); expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0);
expect(profile.onAddRulesProfile).toBeUndefined(); // OpenCode doesn't have onAdd // OpenCode has onRemove and onPostConvert but NOT onAdd
expect(profile.onAddRulesProfile).toBeUndefined();
expect(typeof profile.onRemoveRulesProfile).toBe('function'); expect(typeof profile.onRemoveRulesProfile).toBe('function');
expect(typeof profile.onPostConvertRulesProfile).toBe('function'); expect(typeof profile.onPostConvertRulesProfile).toBe('function');
} }
); );
test.each(profilesWithoutLifecycle)( test.each(profilesWithDeclarativeSlashCommands)(
'should have file mappings without lifecycle functions for %s profile', 'should have file mappings with declarative slashCommands for %s profile',
(profileName) => { (profileName) => {
const profile = getRulesProfile(profileName); const profile = getRulesProfile(profileName);
expect(profile).toBeDefined(); expect(profile).toBeDefined();
// Codex profile has fileMap but no lifecycle functions (simplified) // These profiles use the declarative slashCommands property
// instead of auto-generated lifecycle hooks
expect(profile.fileMap).toBeDefined(); expect(profile.fileMap).toBeDefined();
expect(typeof profile.fileMap).toBe('object'); expect(typeof profile.fileMap).toBe('object');
expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0); expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0);
expect(profile.onAddRulesProfile).toBeUndefined(); // No explicit lifecycle hooks - uses declarative slashCommands
expect(profile.onRemoveRulesProfile).toBeUndefined(); // (slashCommands may be null if @tm/profiles lookup fails in test env)
expect(profile.onPostConvertRulesProfile).toBeUndefined();
} }
); );
}); });

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