From 9ee63e01db4308cf248be3855949c7cd86272b9b Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:53:14 +0100 Subject: [PATCH] feat: add slash commands (#1461) --- .changeset/mode-filtering-slash-commands.md | 17 + .changeset/warm-things-shake.md | 13 + .github/workflows/ci.yml | 89 ++- apps/extension/src/utils/mcpClient.ts | 4 +- .../integration/tools/generate.tool.test.ts | 6 +- package-lock.json | 36 +- .../tm-core/docs/listTasks-architecture.md | 2 +- packages/tm-profiles/package.json | 33 + packages/tm-profiles/src/index.ts | 7 + .../commands/common/analyze-project.ts | 114 +++ .../commands/common/auto-implement-tasks.ts | 114 +++ .../commands/common/command-pipeline.ts | 94 +++ .../slash-commands/commands/common/help.ts | 115 +++ .../slash-commands/commands/common/index.ts | 36 + .../slash-commands/commands/common/learn.ts | 120 ++++ .../commands/common/list-tasks-by-status.ts | 56 ++ .../common/list-tasks-with-subtasks.ts | 45 ++ .../commands/common/list-tasks.ts | 60 ++ .../commands/common/next-task.ts | 83 +++ .../commands/common/project-status.ts | 81 +++ .../commands/common/show-task.ts | 99 +++ .../commands/common/smart-workflow.ts | 72 ++ .../commands/common/sync-readme.ts | 134 ++++ .../slash-commands/commands/common/tm-main.ts | 162 +++++ .../slash-commands/commands/common/to-done.ts | 61 ++ .../commands/common/to-in-progress.ts | 53 ++ .../commands/common/to-pending.ts | 49 ++ .../commands/common/update-single-task.ts | 136 ++++ .../commands/common/update-task.ts | 89 +++ .../commands/common/update-tasks-from-id.ts | 125 ++++ .../src/slash-commands/commands/index.ts | 171 +++++ .../commands/mode-filtering.spec.ts | 312 ++++++++ .../commands/solo/add-dependency.ts | 73 ++ .../commands/solo/add-subtask.ts | 94 +++ .../slash-commands/commands/solo/add-task.ts | 96 +++ .../commands/solo/analyze-complexity.ts | 139 ++++ .../commands/solo/complexity-report.ts | 135 ++++ .../commands/solo/convert-task-to-subtask.ts | 89 +++ .../commands/solo/expand-all-tasks.ts | 68 ++ .../commands/solo/expand-task.ts | 67 ++ .../commands/solo/fix-dependencies.ts | 98 +++ .../commands/solo/generate-tasks.ts | 130 ++++ .../src/slash-commands/commands/solo/index.ts | 49 ++ .../commands/solo/init-project-quick.ts | 64 ++ .../commands/solo/init-project.ts | 68 ++ .../commands/solo/install-taskmaster.ts | 134 ++++ .../commands/solo/parse-prd-with-research.ts | 66 ++ .../slash-commands/commands/solo/parse-prd.ts | 67 ++ .../commands/solo/quick-install-taskmaster.ts | 39 + .../commands/solo/remove-all-subtasks.ts | 110 +++ .../commands/solo/remove-dependency.ts | 80 +++ .../commands/solo/remove-subtask.ts | 102 +++ .../commands/solo/remove-subtasks.ts | 104 +++ .../commands/solo/remove-task.ts | 125 ++++ .../commands/solo/setup-models.ts | 68 ++ .../commands/solo/to-cancelled.ts | 73 ++ .../commands/solo/to-deferred.ts | 65 ++ .../slash-commands/commands/solo/to-review.ts | 58 ++ .../commands/solo/validate-dependencies.ts | 88 +++ .../commands/solo/view-models.ts | 68 ++ .../src/slash-commands/commands/team/goham.ts | 344 +++++++++ .../src/slash-commands/commands/team/index.ts | 6 + .../src/slash-commands/factories.ts | 102 +++ .../tm-profiles/src/slash-commands/index.ts | 43 ++ .../slash-commands/profiles/base-profile.ts | 376 ++++++++++ .../profiles/claude-profile.spec.ts | 354 +++++++++ .../slash-commands/profiles/claude-profile.ts | 58 ++ .../profiles/codex-profile.spec.ts | 429 +++++++++++ .../slash-commands/profiles/codex-profile.ts | 103 +++ .../profiles/cursor-profile.spec.ts | 347 +++++++++ .../slash-commands/profiles/cursor-profile.ts | 37 + .../profiles/gemini-profile.spec.ts | 675 ++++++++++++++++++ .../slash-commands/profiles/gemini-profile.ts | 59 ++ .../src/slash-commands/profiles/index.spec.ts | 458 ++++++++++++ .../src/slash-commands/profiles/index.ts | 97 +++ .../profiles/opencode-profile.spec.ts | 347 +++++++++ .../profiles/opencode-profile.ts | 63 ++ .../profiles/roo-profile.spec.ts | 368 ++++++++++ .../slash-commands/profiles/roo-profile.ts | 67 ++ .../tm-profiles/src/slash-commands/types.ts | 66 ++ .../tm-profiles/src/slash-commands/utils.ts | 46 ++ .../claude-profile.integration.test.ts | 442 ++++++++++++ .../codex-profile.integration.test.ts | 515 +++++++++++++ .../cursor-profile.integration.test.ts | 351 +++++++++ .../gemini-profile.integration.test.ts | 489 +++++++++++++ .../opencode-profile.integration.test.ts | 630 ++++++++++++++++ .../roo-profile.integration.test.ts | 616 ++++++++++++++++ packages/tm-profiles/tsconfig.json | 36 + packages/tm-profiles/vitest.config.ts | 27 + scripts/init.js | 19 +- scripts/modules/commands.js | 15 +- scripts/modules/config-manager.js | 48 ++ src/profiles/base-profile.js | 66 ++ src/profiles/cursor.js | 136 +--- src/utils/rule-transformer.js | 124 +++- .../cursor-init-functionality.test.js | 46 +- .../hamster-rules-distribution.test.js | 74 +- .../base-profile-slash-commands.test.js | 394 ++++++++++ .../unit/profiles/cursor-integration.test.js | 223 +++--- .../profiles/mcp-config-validation.test.js | 26 +- .../profiles/rule-transformer-gemini.test.js | 11 +- 101 files changed, 13425 insertions(+), 313 deletions(-) create mode 100644 .changeset/mode-filtering-slash-commands.md create mode 100644 .changeset/warm-things-shake.md create mode 100644 packages/tm-profiles/package.json create mode 100644 packages/tm-profiles/src/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/analyze-project.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/auto-implement-tasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/command-pipeline.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/help.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/learn.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/list-tasks-by-status.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/list-tasks-with-subtasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/list-tasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/next-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/project-status.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/show-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/smart-workflow.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/sync-readme.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/tm-main.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/to-done.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/to-in-progress.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/to-pending.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/update-single-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/update-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/common/update-tasks-from-id.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/mode-filtering.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/add-dependency.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/add-subtask.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/add-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/analyze-complexity.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/complexity-report.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/convert-task-to-subtask.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/expand-all-tasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/expand-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/fix-dependencies.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/generate-tasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/init-project-quick.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/init-project.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/install-taskmaster.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/parse-prd-with-research.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/parse-prd.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/quick-install-taskmaster.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/remove-all-subtasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/remove-dependency.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/remove-subtask.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/remove-subtasks.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/remove-task.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/setup-models.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/to-cancelled.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/to-deferred.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/to-review.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/validate-dependencies.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/solo/view-models.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/team/goham.ts create mode 100644 packages/tm-profiles/src/slash-commands/commands/team/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/factories.ts create mode 100644 packages/tm-profiles/src/slash-commands/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/base-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/claude-profile.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/claude-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/codex-profile.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/codex-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/cursor-profile.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/cursor-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/gemini-profile.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/gemini-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/index.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/index.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/opencode-profile.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/opencode-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/roo-profile.spec.ts create mode 100644 packages/tm-profiles/src/slash-commands/profiles/roo-profile.ts create mode 100644 packages/tm-profiles/src/slash-commands/types.ts create mode 100644 packages/tm-profiles/src/slash-commands/utils.ts create mode 100644 packages/tm-profiles/tests/integration/claude-profile.integration.test.ts create mode 100644 packages/tm-profiles/tests/integration/codex-profile.integration.test.ts create mode 100644 packages/tm-profiles/tests/integration/cursor-profile.integration.test.ts create mode 100644 packages/tm-profiles/tests/integration/gemini-profile.integration.test.ts create mode 100644 packages/tm-profiles/tests/integration/opencode-profile.integration.test.ts create mode 100644 packages/tm-profiles/tests/integration/roo-profile.integration.test.ts create mode 100644 packages/tm-profiles/tsconfig.json create mode 100644 packages/tm-profiles/vitest.config.ts create mode 100644 tests/unit/profiles/base-profile-slash-commands.test.js diff --git a/.changeset/mode-filtering-slash-commands.md b/.changeset/mode-filtering-slash-commands.md new file mode 100644 index 00000000..c798afd8 --- /dev/null +++ b/.changeset/mode-filtering-slash-commands.md @@ -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. diff --git a/.changeset/warm-things-shake.md b/.changeset/warm-things-shake.md new file mode 100644 index 00000000..af1950bd --- /dev/null +++ b/.changeset/warm-things-shake.md @@ -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 ` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167e86dc..eee2a41a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,24 +18,54 @@ permissions: env: DO_NOT_TRACK: 1 NODE_ENV: development + NODE_VERSION: 20 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 format-check: name: Format Check runs-on: ubuntu-latest + needs: install steps: - uses: actions/checkout@v4 - with: - fetch-depth: 2 - uses: actions/setup-node@v4 with: - node-version: 20 - cache: "npm" + node-version: ${{ env.NODE_VERSION }} + + - 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 - run: npm install --frozen-lockfile --prefer-offline + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci timeout-minutes: 5 - name: Format Check @@ -62,7 +92,7 @@ jobs: - uses: actions/setup-node@v4 if: steps.changes.outputs.changesets == 'true' with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} cache: "npm" - name: Validate changeset package references @@ -78,18 +108,24 @@ jobs: name: Typecheck timeout-minutes: 10 runs-on: ubuntu-latest + needs: install steps: - uses: actions/checkout@v4 - with: - fetch-depth: 2 - uses: actions/setup-node@v4 with: - node-version: 20 - cache: "npm" + node-version: ${{ env.NODE_VERSION }} + + - 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 - run: npm install --frozen-lockfile --prefer-offline + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci timeout-minutes: 5 - name: Typecheck @@ -101,18 +137,24 @@ jobs: build: name: Build runs-on: ubuntu-latest + needs: install steps: - uses: actions/checkout@v4 - with: - fetch-depth: 2 - uses: actions/setup-node@v4 with: - node-version: 20 - cache: "npm" + node-version: ${{ env.NODE_VERSION }} + + - 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 - run: npm install --frozen-lockfile --prefer-offline + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci timeout-minutes: 5 - name: Build @@ -139,16 +181,21 @@ jobs: if: always() && !cancelled() && !contains(needs.*.result, 'failure') steps: - uses: actions/checkout@v4 - with: - fetch-depth: 2 - uses: actions/setup-node@v4 with: - node-version: 20 - cache: "npm" + node-version: ${{ env.NODE_VERSION }} + + - 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 - run: npm install --frozen-lockfile --prefer-offline + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci timeout-minutes: 5 - name: Download build artifacts diff --git a/apps/extension/src/utils/mcpClient.ts b/apps/extension/src/utils/mcpClient.ts index f9580293..26c79327 100644 --- a/apps/extension/src/utils/mcpClient.ts +++ b/apps/extension/src/utils/mcpClient.ts @@ -148,9 +148,7 @@ export class MCPClientManager { version: '1.0.0' }, { - capabilities: { - tools: {} - } + capabilities: {} } ); diff --git a/apps/mcp/tests/integration/tools/generate.tool.test.ts b/apps/mcp/tests/integration/tools/generate.tool.test.ts index 46a2afcb..db5a377f 100644 --- a/apps/mcp/tests/integration/tools/generate.tool.test.ts +++ b/apps/mcp/tests/integration/tools/generate.tool.test.ts @@ -72,7 +72,11 @@ describe('generate MCP tool', () => { const output = execSync( `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: "" }] } diff --git a/package-lock.json b/package-lock.json index b367757b..5e247358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "task-master-ai", - "version": "0.37.2", + "version": "0.38.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-master-ai", - "version": "0.37.2", + "version": "0.38.0-rc.0", "license": "MIT WITH Commons-Clause", "workspaces": [ "apps/*", @@ -204,7 +204,7 @@ "react-dom": "^19.0.0", "tailwind-merge": "^3.3.1", "tailwindcss": "4.1.11", - "task-master-ai": "*", + "task-master-ai": "0.38.0-rc.0", "typescript": "^5.9.2" }, "engines": { @@ -13432,6 +13432,10 @@ "resolved": "apps/mcp", "link": true }, + "node_modules/@tm/profiles": { + "resolved": "packages/tm-profiles", + "link": true + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -36630,6 +36634,32 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "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" } } } diff --git a/packages/tm-core/docs/listTasks-architecture.md b/packages/tm-core/docs/listTasks-architecture.md index 84399c72..5f8b0f26 100644 --- a/packages/tm-core/docs/listTasks-architecture.md +++ b/packages/tm-core/docs/listTasks-architecture.md @@ -67,7 +67,7 @@ listTasks(tasksPath, statusFilter, reportPath, withSubtasks, outputFormat, conte const tmCore = createTaskMasterCore(projectPath, { storage: { type: 'api', // or 'file' - apiEndpoint: 'https://hamster.ai/api', + apiEndpoint: 'https://tryhamster.com', apiAccessToken: 'xxx' } }); diff --git a/packages/tm-profiles/package.json b/packages/tm-profiles/package.json new file mode 100644 index 00000000..53ace9bc --- /dev/null +++ b/packages/tm-profiles/package.json @@ -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": "" +} diff --git a/packages/tm-profiles/src/index.ts b/packages/tm-profiles/src/index.ts new file mode 100644 index 00000000..1c79fb21 --- /dev/null +++ b/packages/tm-profiles/src/index.ts @@ -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'; diff --git a/packages/tm-profiles/src/slash-commands/commands/common/analyze-project.ts b/packages/tm-profiles/src/slash-commands/commands/common/analyze-project.ts new file mode 100644 index 00000000..a257f17d --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/analyze-project.ts @@ -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.` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/auto-implement-tasks.ts b/packages/tm-profiles/src/slash-commands/commands/common/auto-implement-tasks.ts new file mode 100644 index 00000000..2059d802 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/auto-implement-tasks.ts @@ -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.` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/command-pipeline.ts b/packages/tm-profiles/src/slash-commands/commands/common/command-pipeline.ts new file mode 100644 index 00000000..885b3dec --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/command-pipeline.ts @@ -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', + '', + `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\`` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/help.ts b/packages/tm-profiles/src/slash-commands/commands/common/help.ts new file mode 100644 index 00000000..04e62385 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/help.ts @@ -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.` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/index.ts b/packages/tm-profiles/src/slash-commands/commands/common/index.ts new file mode 100644 index 00000000..f13ae03e --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/index.ts @@ -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'; diff --git a/packages/tm-profiles/src/slash-commands/commands/common/learn.ts b/packages/tm-profiles/src/slash-commands/commands/common/learn.ts new file mode 100644 index 00000000..df9c8a04 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/learn.ts @@ -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 + → Creates tasks from requirements + +2. /project:task-master:parse-prd + → 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 \` +- "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 +/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 ` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/list-tasks-by-status.ts b/packages/tm-profiles/src/slash-commands/commands/common/list-tasks-by-status.ts new file mode 100644 index 00000000..7692cd49 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/list-tasks-by-status.ts @@ -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', + '', + `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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/list-tasks-with-subtasks.ts b/packages/tm-profiles/src/slash-commands/commands/common/list-tasks-with-subtasks.ts new file mode 100644 index 00000000..b359152d --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/list-tasks-with-subtasks.ts @@ -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.` +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/list-tasks.ts b/packages/tm-profiles/src/slash-commands/commands/common/list-tasks.ts new file mode 100644 index 00000000..cde94f99 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/list-tasks.ts @@ -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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/next-task.ts b/packages/tm-profiles/src/slash-commands/commands/common/next-task.ts new file mode 100644 index 00000000..3d14bc9f --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/next-task.ts @@ -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.` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/project-status.ts b/packages/tm-profiles/src/slash-commands/commands/common/project-status.ts new file mode 100644 index 00000000..30f098a7 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/project-status.ts @@ -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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/show-task.ts b/packages/tm-profiles/src/slash-commands/commands/common/show-task.ts new file mode 100644 index 00000000..149d1725 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/show-task.ts @@ -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', + '', + `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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/smart-workflow.ts b/packages/tm-profiles/src/slash-commands/commands/common/smart-workflow.ts new file mode 100644 index 00000000..de3b3da0 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/smart-workflow.ts @@ -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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/sync-readme.ts b/packages/tm-profiles/src/slash-commands/commands/common/sync-readme.ts new file mode 100644 index 00000000..4fa4adfd --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/sync-readme.ts @@ -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=] +\`\`\` + +## 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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/tm-main.ts b/packages/tm-profiles/src/slash-commands/commands/common/tm-main.ts new file mode 100644 index 00000000..adf2357f --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/tm-main.ts @@ -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.` +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/to-done.ts b/packages/tm-profiles/src/slash-commands/commands/common/to-done.ts new file mode 100644 index 00000000..5fdff4d6 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/to-done.ts @@ -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', + '', + `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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/to-in-progress.ts b/packages/tm-profiles/src/slash-commands/commands/common/to-in-progress.ts new file mode 100644 index 00000000..8dcf5280 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/to-in-progress.ts @@ -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', + '', + `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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/to-pending.ts b/packages/tm-profiles/src/slash-commands/commands/common/to-pending.ts new file mode 100644 index 00000000..715d916d --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/to-pending.ts @@ -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', + '', + `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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/update-single-task.ts b/packages/tm-profiles/src/slash-commands/commands/common/update-single-task.ts new file mode 100644 index 00000000..b09c98f9 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/update-single-task.ts @@ -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', + ' ', + `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= --prompt="" +\`\`\` + +## 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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/update-task.ts b/packages/tm-profiles/src/slash-commands/commands/common/update-task.ts new file mode 100644 index 00000000..569f8316 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/update-task.ts @@ -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 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.` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/common/update-tasks-from-id.ts b/packages/tm-profiles/src/slash-commands/commands/common/update-tasks-from-id.ts new file mode 100644 index 00000000..072f19c0 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/common/update-tasks-from-id.ts @@ -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', + ' ', + `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= --prompt="" +\`\`\` + +## 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` +); diff --git a/packages/tm-profiles/src/slash-commands/commands/index.ts b/packages/tm-profiles/src/slash-commands/commands/index.ts new file mode 100644 index 00000000..e1c3f66c --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/index.ts @@ -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'; diff --git a/packages/tm-profiles/src/slash-commands/commands/mode-filtering.spec.ts b/packages/tm-profiles/src/slash-commands/commands/mode-filtering.spec.ts new file mode 100644 index 00000000..997b1172 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/mode-filtering.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/add-dependency.ts b/packages/tm-profiles/src/slash-commands/commands/solo/add-dependency.ts new file mode 100644 index 00000000..b6dfc8a8 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/add-dependency.ts @@ -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', + ' ', + `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= --depends-on= +\`\`\` + +## 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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/add-subtask.ts b/packages/tm-profiles/src/slash-commands/commands/solo/add-subtask.ts new file mode 100644 index 00000000..c18f8374 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/add-subtask.ts @@ -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', + ' ', + `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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/add-task.ts b/packages/tm-profiles/src/slash-commands/commands/solo/add-task.ts new file mode 100644 index 00000000..889404aa --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/add-task.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/analyze-complexity.ts b/packages/tm-profiles/src/slash-commands/commands/solo/analyze-complexity.ts new file mode 100644 index 00000000..66b175c0 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/analyze-complexity.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/complexity-report.ts b/packages/tm-profiles/src/slash-commands/commands/solo/complexity-report.ts new file mode 100644 index 00000000..f02e76a0 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/complexity-report.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/convert-task-to-subtask.ts b/packages/tm-profiles/src/slash-commands/commands/solo/convert-task-to-subtask.ts new file mode 100644 index 00000000..14b37f58 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/convert-task-to-subtask.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/expand-all-tasks.ts b/packages/tm-profiles/src/slash-commands/commands/solo/expand-all-tasks.ts new file mode 100644 index 00000000..d423c6c0 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/expand-all-tasks.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/expand-task.ts b/packages/tm-profiles/src/slash-commands/commands/solo/expand-task.ts new file mode 100644 index 00000000..c4f3e355 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/expand-task.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/fix-dependencies.ts b/packages/tm-profiles/src/slash-commands/commands/solo/fix-dependencies.ts new file mode 100644 index 00000000..1d654bd5 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/fix-dependencies.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/generate-tasks.ts b/packages/tm-profiles/src/slash-commands/commands/solo/generate-tasks.ts new file mode 100644 index 00000000..820517e5 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/generate-tasks.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/index.ts b/packages/tm-profiles/src/slash-commands/commands/solo/index.ts new file mode 100644 index 00000000..5eae865f --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/index.ts @@ -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'; diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/init-project-quick.ts b/packages/tm-profiles/src/slash-commands/commands/solo/init-project-quick.ts new file mode 100644 index 00000000..92b8bf42 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/init-project-quick.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/init-project.ts b/packages/tm-profiles/src/slash-commands/commands/solo/init-project.ts new file mode 100644 index 00000000..2a4ec48a --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/init-project.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/install-taskmaster.ts b/packages/tm-profiles/src/slash-commands/commands/solo/install-taskmaster.ts new file mode 100644 index 00000000..5b6be2db --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/install-taskmaster.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/parse-prd-with-research.ts b/packages/tm-profiles/src/slash-commands/commands/solo/parse-prd-with-research.ts new file mode 100644 index 00000000..c075f38d --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/parse-prd-with-research.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/parse-prd.ts b/packages/tm-profiles/src/slash-commands/commands/solo/parse-prd.ts new file mode 100644 index 00000000..3c45d7a5 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/parse-prd.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/quick-install-taskmaster.ts b/packages/tm-profiles/src/slash-commands/commands/solo/quick-install-taskmaster.ts new file mode 100644 index 00000000..74101a25 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/quick-install-taskmaster.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/remove-all-subtasks.ts b/packages/tm-profiles/src/slash-commands/commands/solo/remove-all-subtasks.ts new file mode 100644 index 00000000..cfb30810 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/remove-all-subtasks.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/remove-dependency.ts b/packages/tm-profiles/src/slash-commands/commands/solo/remove-dependency.ts new file mode 100644 index 00000000..7c5a5f3c --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/remove-dependency.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/remove-subtask.ts b/packages/tm-profiles/src/slash-commands/commands/solo/remove-subtask.ts new file mode 100644 index 00000000..a52f1a78 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/remove-subtask.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/remove-subtasks.ts b/packages/tm-profiles/src/slash-commands/commands/solo/remove-subtasks.ts new file mode 100644 index 00000000..68685f8e --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/remove-subtasks.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/remove-task.ts b/packages/tm-profiles/src/slash-commands/commands/solo/remove-task.ts new file mode 100644 index 00000000..f44a17f9 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/remove-task.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/setup-models.ts b/packages/tm-profiles/src/slash-commands/commands/solo/setup-models.ts new file mode 100644 index 00000000..3c8dac38 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/setup-models.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/to-cancelled.ts b/packages/tm-profiles/src/slash-commands/commands/solo/to-cancelled.ts new file mode 100644 index 00000000..7ca921b6 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/to-cancelled.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/to-deferred.ts b/packages/tm-profiles/src/slash-commands/commands/solo/to-deferred.ts new file mode 100644 index 00000000..dbe7e042 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/to-deferred.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/to-review.ts b/packages/tm-profiles/src/slash-commands/commands/solo/to-review.ts new file mode 100644 index 00000000..4173dab6 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/to-review.ts @@ -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' +); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/validate-dependencies.ts b/packages/tm-profiles/src/slash-commands/commands/solo/validate-dependencies.ts new file mode 100644 index 00000000..9062a4d6 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/validate-dependencies.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/solo/view-models.ts b/packages/tm-profiles/src/slash-commands/commands/solo/view-models.ts new file mode 100644 index 00000000..ee2bf182 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/solo/view-models.ts @@ -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' +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/team/goham.ts b/packages/tm-profiles/src/slash-commands/commands/team/goham.ts new file mode 100644 index 00000000..77bded5b --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/team/goham.ts @@ -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 +\`\`\` +` +}); diff --git a/packages/tm-profiles/src/slash-commands/commands/team/index.ts b/packages/tm-profiles/src/slash-commands/commands/team/index.ts new file mode 100644 index 00000000..ef7d2710 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/commands/team/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Team Mode Commands + * Commands that only work with API-based storage (Hamster cloud integration). + */ + +export { goham } from './goham.js'; diff --git a/packages/tm-profiles/src/slash-commands/factories.ts b/packages/tm-profiles/src/slash-commands/factories.ts new file mode 100644 index 00000000..2f2cbf55 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/factories.ts @@ -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 + }; +} diff --git a/packages/tm-profiles/src/slash-commands/index.ts b/packages/tm-profiles/src/slash-commands/index.ts new file mode 100644 index 00000000..5daccfa4 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/index.ts @@ -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'; diff --git a/packages/tm-profiles/src/slash-commands/profiles/base-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/base-profile.ts new file mode 100644 index 00000000..f8912ec1 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/base-profile.ts @@ -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 }); + } +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/claude-profile.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/claude-profile.spec.ts new file mode 100644 index 00000000..27432a7f --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/claude-profile.spec.ts @@ -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([]); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/claude-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/claude-profile.ts new file mode 100644 index 00000000..fb04c1f8 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/claude-profile.ts @@ -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'); + } +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/codex-profile.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/codex-profile.spec.ts new file mode 100644 index 00000000..4362bea7 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/codex-profile.spec.ts @@ -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')); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/codex-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/codex-profile.ts new file mode 100644 index 00000000..b514bba0 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/codex-profile.ts @@ -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'); + } +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/cursor-profile.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/cursor-profile.spec.ts new file mode 100644 index 00000000..613e5b23 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/cursor-profile.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/cursor-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/cursor-profile.ts new file mode 100644 index 00000000..f5a95be4 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/cursor-profile.ts @@ -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 + }; + } +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/gemini-profile.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/gemini-profile.spec.ts new file mode 100644 index 00000000..df4421aa --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/gemini-profile.spec.ts @@ -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("\\'"); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/gemini-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/gemini-profile.ts new file mode 100644 index 00000000..157b7a09 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/gemini-profile.ts @@ -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, '""\\"'); + } +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/index.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/index.spec.ts new file mode 100644 index 00000000..76a63a9e --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/index.spec.ts @@ -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); + } + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/index.ts b/packages/tm-profiles/src/slash-commands/profiles/index.ts new file mode 100644 index 00000000..2437ad2e --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/index.ts @@ -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); +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/opencode-profile.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/opencode-profile.spec.ts new file mode 100644 index 00000000..5f1f1735 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/opencode-profile.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/opencode-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/opencode-profile.ts new file mode 100644 index 00000000..1e93c51d --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/opencode-profile.ts @@ -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'); + } +} diff --git a/packages/tm-profiles/src/slash-commands/profiles/roo-profile.spec.ts b/packages/tm-profiles/src/slash-commands/profiles/roo-profile.spec.ts new file mode 100644 index 00000000..25cad728 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/roo-profile.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/tm-profiles/src/slash-commands/profiles/roo-profile.ts b/packages/tm-profiles/src/slash-commands/profiles/roo-profile.ts new file mode 100644 index 00000000..062ad0c4 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/profiles/roo-profile.ts @@ -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'); + } +} diff --git a/packages/tm-profiles/src/slash-commands/types.ts b/packages/tm-profiles/src/slash-commands/types.ts new file mode 100644 index 00000000..ec9755ba --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/types.ts @@ -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; +} diff --git a/packages/tm-profiles/src/slash-commands/utils.ts b/packages/tm-profiles/src/slash-commands/utils.ts new file mode 100644 index 00000000..3ea13e58 --- /dev/null +++ b/packages/tm-profiles/src/slash-commands/utils.ts @@ -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; +} diff --git a/packages/tm-profiles/tests/integration/claude-profile.integration.test.ts b/packages/tm-profiles/tests/integration/claude-profile.integration.test.ts new file mode 100644 index 00000000..6e1f9436 --- /dev/null +++ b/packages/tm-profiles/tests/integration/claude-profile.integration.test.ts @@ -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```'); + }); + }); +}); diff --git a/packages/tm-profiles/tests/integration/codex-profile.integration.test.ts b/packages/tm-profiles/tests/integration/codex-profile.integration.test.ts new file mode 100644 index 00000000..ba44916c --- /dev/null +++ b/packages/tm-profiles/tests/integration/codex-profile.integration.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/tm-profiles/tests/integration/cursor-profile.integration.test.ts b/packages/tm-profiles/tests/integration/cursor-profile.integration.test.ts new file mode 100644 index 00000000..025676c4 --- /dev/null +++ b/packages/tm-profiles/tests/integration/cursor-profile.integration.test.ts @@ -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.'); + }); + }); +}); diff --git a/packages/tm-profiles/tests/integration/gemini-profile.integration.test.ts b/packages/tm-profiles/tests/integration/gemini-profile.integration.test.ts new file mode 100644 index 00000000..dff76c47 --- /dev/null +++ b/packages/tm-profiles/tests/integration/gemini-profile.integration.test.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-profiles/tests/integration/opencode-profile.integration.test.ts b/packages/tm-profiles/tests/integration/opencode-profile.integration.test.ts new file mode 100644 index 00000000..1d3dc1ed --- /dev/null +++ b/packages/tm-profiles/tests/integration/opencode-profile.integration.test.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-profiles/tests/integration/roo-profile.integration.test.ts b/packages/tm-profiles/tests/integration/roo-profile.integration.test.ts new file mode 100644 index 00000000..960c7a4e --- /dev/null +++ b/packages/tm-profiles/tests/integration/roo-profile.integration.test.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-profiles/tsconfig.json b/packages/tm-profiles/tsconfig.json new file mode 100644 index 00000000..5d52cbf0 --- /dev/null +++ b/packages/tm-profiles/tsconfig.json @@ -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"] +} diff --git a/packages/tm-profiles/vitest.config.ts b/packages/tm-profiles/vitest.config.ts new file mode 100644 index 00000000..7732bd05 --- /dev/null +++ b/packages/tm-profiles/vitest.config.ts @@ -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 + } + } + } +}); diff --git a/scripts/init.js b/scripts/init.js index b0bd3114..bc876356 100755 --- a/scripts/init.js +++ b/scripts/init.js @@ -716,12 +716,21 @@ function updateStorageConfig(configPath, selectedStorage, authCredentials) { process.env.TM_PUBLIC_BASE_DOMAIN || '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 // We don't store it in config.json for security reasons log('debug', 'Connected to Hamster Studio'); } else { // Configure for local file storage 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'); } @@ -843,10 +852,18 @@ async function createProjectStructure( }; // 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) { const profile = getRulesProfile(profileName); 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) } else { log('warn', `Unknown rule profile: ${profileName}`); diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index b38a3074..2b5218b4 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -78,6 +78,7 @@ import { getConfig, getDebugFlag, getDefaultNumTasks, + getOperatingMode, isApiKeySet, isConfigFilePresent, setSuppressConfigWarnings @@ -4388,11 +4389,16 @@ Examples: `--${RULES_SETUP_ACTION}`, '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( 'after', ` Examples: $ 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_SETUP_ACTION} # Interactive setup to select rule profiles` ) @@ -4447,10 +4453,11 @@ Examples: continue; } const profileConfig = getRulesProfile(profile); - + const mode = await getOperatingMode(options.mode); const addResult = convertAllRulesToProfileRules( projectRoot, - profileConfig + profileConfig, + { mode } ); console.log(chalk.green(generateProfileSummary(profile, addResult))); @@ -4525,9 +4532,11 @@ Examples: if (action === RULES_ACTIONS.ADD) { console.log(chalk.blue(`Adding rules for profile: ${profile}...`)); + const mode = await getOperatingMode(options.mode); const addResult = convertAllRulesToProfileRules( projectRoot, - profileConfig + profileConfig, + { mode } ); console.log( chalk.blue(`Completed adding rules for profile: ${profile}`) diff --git a/scripts/modules/config-manager.js b/scripts/modules/config-manager.js index da044bae..96ac59f2 100644 --- a/scripts/modules/config-manager.js +++ b/scripts/modules/config-manager.js @@ -3,6 +3,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { ALL_PROVIDERS, + AuthManager, CUSTOM_PROVIDERS, CUSTOM_PROVIDERS_ARRAY, VALIDATED_PROVIDERS @@ -1189,6 +1190,51 @@ function getBaseUrlForRole(role, explicitRoot = null) { 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 const providersWithoutApiKeys = [ CUSTOM_PROVIDERS.OLLAMA, @@ -1257,6 +1303,8 @@ export { getAnonymousTelemetryEnabled, getParametersForRole, getUserId, + // Operating mode + getOperatingMode, // API Key Checkers (still relevant) isApiKeySet, getMcpApiKeyStatus, diff --git a/src/profiles/base-profile.js b/src/profiles/base-profile.js index 4544e76a..b39f8899 100644 --- a/src/profiles/base-profile.js +++ b/src/profiles/base-profile.js @@ -1,5 +1,52 @@ // Base profile factory for rule-transformer 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 @@ -221,6 +268,23 @@ export function createProfile(editorConfig) { : 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 { profileName: name, // Use name for programmatic access (tests expect this) displayName: displayName, // Keep displayName for UI purposes @@ -236,6 +300,8 @@ export function createProfile(editorConfig) { conversionConfig, getTargetRuleFilename, targetExtension, + // Declarative slash command config - rule-transformer handles execution + slashCommands, // Optional lifecycle hooks ...(onAdd && { onAddRulesProfile: onAdd }), ...(onRemove && { onRemoveRulesProfile: onRemove }), diff --git a/src/profiles/cursor.js b/src/profiles/cursor.js index 59019bac..a0e33c64 100644 --- a/src/profiles/cursor.js +++ b/src/profiles/cursor.js @@ -1,135 +1,6 @@ -import fs from 'fs'; // Cursor conversion profile for rule-transformer -import path from 'path'; -import { log } from '../../scripts/modules/utils.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 export const cursorProfile = createProfile({ name: 'cursor', @@ -137,10 +8,5 @@ export const cursorProfile = createProfile({ url: 'cursor.so', docsUrl: 'docs.cursor.com', targetExtension: '.mdc', // Cursor keeps .mdc extension - supportsRulesSubdirectories: true, - onAdd: onAddRulesProfile, - onRemove: onRemoveRulesProfile + supportsRulesSubdirectories: true }); - -// Export lifecycle functions separately to avoid naming conflicts -export { onAddRulesProfile, onRemoveRulesProfile }; diff --git a/src/utils/rule-transformer.js b/src/utils/rule-transformer.js index 5d198744..64825f3c 100644 --- a/src/utils/rule-transformer.js +++ b/src/utils/rule-transformer.js @@ -24,6 +24,9 @@ import { RULE_PROFILES } from '../constants/profiles.js'; // --- Profile Imports --- import * as profilesModule from '../profiles/index.js'; +// Import rule filtering +import { filterRulesByMode } from '../profiles/base-profile.js'; + export function isValidProfile(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); + 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 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) if (typeof profile.onAddRulesProfile === 'function') { try { @@ -225,7 +280,11 @@ export function convertAllRulesToProfileRules(projectRoot, profile) { } // 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) { // Only create rules directory if we have files to copy if (!fs.existsSync(targetDir)) { @@ -246,7 +305,7 @@ export function convertAllRulesToProfileRules(projectRoot, profile) { continue; } - const targetFilename = profile.fileMap[sourceFile]; + const targetFilename = filteredFileMap[sourceFile]; const targetPath = path.join(targetDir, targetFilename); // 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 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)) { const remainingContents = fs.readdirSync(profileDir); if (remainingContents.length === 0 && profile.profileDir !== '.') { diff --git a/tests/integration/profiles/cursor-init-functionality.test.js b/tests/integration/profiles/cursor-init-functionality.test.js index b17239bf..804f8312 100644 --- a/tests/integration/profiles/cursor-init-functionality.test.js +++ b/tests/integration/profiles/cursor-init-functionality.test.js @@ -55,32 +55,28 @@ describe('Cursor Profile Initialization Functionality', () => { expect(cursorProfile.conversionConfig.toolNames.search).toBe('search'); }); - test('cursor.js has lifecycle functions for command copying', () => { - // Check that the source file contains our new lifecycle functions - expect(cursorProfileContent).toContain('function onAddRulesProfile'); - expect(cursorProfileContent).toContain('function onRemoveRulesProfile'); - expect(cursorProfileContent).toContain('copyRecursiveSync'); - expect(cursorProfileContent).toContain('removeDirectoryRecursive'); + test('cursor.js uses factory pattern from base-profile', () => { + // The new architecture uses createProfile from base-profile.js + // which auto-generates lifecycle hooks via @tm/profiles package + expect(cursorProfileContent).toContain('import { createProfile }'); + expect(cursorProfileContent).toContain('createProfile('); + + // 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', () => { - // Check that the onAddRulesProfile function copies from the correct source - expect(cursorProfileContent).toContain( - "path.join(assetsDir, 'claude', 'commands')" - ); - // Destination path is built via a resolver to handle both project root and rules dir - expect(cursorProfileContent).toContain('resolveCursorProfileDir('); - expect(cursorProfileContent).toMatch( - /path\.join\(\s*profileDir\s*,\s*['"]commands['"]\s*\)/ - ); - expect(cursorProfileContent).toContain( - 'copyRecursiveSync(commandsSourceDir, commandsDestDir)' - ); - - // 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'); + test('cursor profile has declarative slashCommands property via @tm/profiles', () => { + // The new architecture uses a declarative slashCommands property + // instead of lifecycle hooks - rule-transformer handles execution + // 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 + if (cursorProfile.slashCommands) { + expect(cursorProfile.slashCommands.profile).toBeDefined(); + expect(cursorProfile.slashCommands.commands).toBeDefined(); + } + // The cursor profile should NOT have explicit lifecycle hooks + // (it uses the declarative slashCommands approach) + expect(cursorProfileContent).not.toContain('onAdd:'); + expect(cursorProfileContent).not.toContain('onRemove:'); }); }); diff --git a/tests/integration/profiles/hamster-rules-distribution.test.js b/tests/integration/profiles/hamster-rules-distribution.test.js index a01c5f17..8a5ab9c8 100644 --- a/tests/integration/profiles/hamster-rules-distribution.test.js +++ b/tests/integration/profiles/hamster-rules-distribution.test.js @@ -9,7 +9,8 @@ import * as profilesModule from '../../../src/profiles/index.js'; * Integration tests for hamster rules distribution across all profiles. * * 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', () => { 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', () => { - // Test each profile that should receive hamster rules + describe('Rules add command distributes hamster file in team mode', () => { + // Test each profile that should receive hamster rules when --mode=team 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 const tempDir = fs.mkdtempSync( path.join(os.tmpdir(), `tm-hamster-test-${profile}-`) ); try { - // Run the rules add command - execSync(`node ${CLI_PATH} rules add ${profile}`, { + // Run the rules add command with team mode (hamster.mdc is team-only) + execSync(`node ${CLI_PATH} rules add ${profile} --mode=team`, { cwd: tempDir, stdio: 'pipe', 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', () => { // These profiles use different mechanisms (CLAUDE.md, AGENTS.md, etc.) // and don't include the defaultFileMap rules diff --git a/tests/unit/profiles/base-profile-slash-commands.test.js b/tests/unit/profiles/base-profile-slash-commands.test.js new file mode 100644 index 00000000..bec25484 --- /dev/null +++ b/tests/unit/profiles/base-profile-slash-commands.test.js @@ -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'); + }); + }); +}); diff --git a/tests/unit/profiles/cursor-integration.test.js b/tests/unit/profiles/cursor-integration.test.js index 97f486ec..00d2a5ac 100644 --- a/tests/unit/profiles/cursor-integration.test.js +++ b/tests/unit/profiles/cursor-integration.test.js @@ -27,10 +27,26 @@ await jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({ 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 -const { cursorProfile, onAddRulesProfile, onRemoveRulesProfile } = await import( - '../../../src/profiles/cursor.js' -); +const { cursorProfile } = await import('../../../src/profiles/cursor.js'); describe('Cursor Integration', () => { let tempDir; @@ -95,126 +111,137 @@ describe('Cursor Integration', () => { ); }); - test('cursor profile has lifecycle functions for command copying', () => { - // Assert that the profile exports the lifecycle functions - expect(typeof onAddRulesProfile).toBe('function'); - expect(typeof onRemoveRulesProfile).toBe('function'); - expect(cursorProfile.onAddRulesProfile).toBe(onAddRulesProfile); - expect(cursorProfile.onRemoveRulesProfile).toBe(onRemoveRulesProfile); + test('cursor profile has declarative slash commands configuration', () => { + // The new architecture uses declarative slashCommands property + // instead of lifecycle hooks - rule-transformer handles execution + expect(cursorProfile.slashCommands).toBeDefined(); + expect(cursorProfile.slashCommands.profile).toBeDefined(); + expect(cursorProfile.slashCommands.commands).toBeDefined(); + expect(cursorProfile.slashCommands.profile.supportsCommands).toBe(true); }); - describe('command copying lifecycle', () => { - let mockAssetsDir; + test('cursor profile has correct basic configuration', () => { + 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; beforeEach(() => { - mockAssetsDir = path.join(tempDir, 'assets'); mockTargetDir = path.join(tempDir, 'target'); // Reset all mocks jest.clearAllMocks(); + mockAddSlashCommands.mockReset(); + mockRemoveSlashCommands.mockReset(); - // Mock fs methods for the lifecycle functions - jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - const pathStr = filePath.toString(); - if (pathStr.includes('claude/commands')) { - return true; // Mock that source commands exist - } - return false; + // Default mock return values + mockAddSlashCommands.mockReturnValue({ + success: true, + count: 10, + directory: path.join(mockTargetDir, '.cursor', 'commands', 'tm'), + files: ['help.md', 'next-task.md'] + }); + 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(() => { jest.restoreAllMocks(); }); - test('onAddRulesProfile copies commands from assets to .cursor/commands', () => { - // Detect if cpSync exists and set up appropriate spy - if (fs.cpSync) { - 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); + test('slashCommands.profile can add slash commands', () => { + // The rule-transformer uses slashCommands.profile.addSlashCommands + const { profile, commands } = cursorProfile.slashCommands; // Act - onAddRulesProfile(mockTargetDir, mockAssetsDir); + profile.addSlashCommands(mockTargetDir, commands); - // Assert - should not attempt to copy anything - expect(fs.mkdirSync).not.toHaveBeenCalled(); - expect(fs.copyFileSync).not.toHaveBeenCalled(); - }); - - 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 } + // Assert - mock was called + expect(mockAddSlashCommands).toHaveBeenCalledWith( + mockTargetDir, + commands ); }); - test('onRemoveRulesProfile handles missing directory gracefully', () => { - // Arrange - mock directory doesn't exist - jest.spyOn(fs, 'existsSync').mockImplementation(() => false); - - // Act - onRemoveRulesProfile(mockTargetDir); - - // Assert - should still return true but not attempt removal - 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'); + test('slashCommands.profile handles add errors gracefully', () => { + // Arrange - mock addSlashCommands failure + mockAddSlashCommands.mockReturnValue({ + success: false, + count: 0, + directory: '', + files: [], + error: 'Permission denied' }); - // Act & Assert - should not throw - expect(() => onRemoveRulesProfile(mockTargetDir)).not.toThrow(); + const { profile, commands } = cursorProfile.slashCommands; + + // 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'); }); }); }); diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 22f4ff4b..e836d7ad 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -433,7 +433,13 @@ describe('MCP Configuration Validation', () => { ]; const profilesWithLifecycle = ['amp', 'claude']; 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)( 'should have file mappings for %s profile', @@ -466,28 +472,30 @@ describe('MCP Configuration Validation', () => { (profileName) => { const profile = getRulesProfile(profileName); 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(typeof profile.fileMap).toBe('object'); 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.onPostConvertRulesProfile).toBe('function'); } ); - test.each(profilesWithoutLifecycle)( - 'should have file mappings without lifecycle functions for %s profile', + test.each(profilesWithDeclarativeSlashCommands)( + 'should have file mappings with declarative slashCommands for %s profile', (profileName) => { const profile = getRulesProfile(profileName); 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(typeof profile.fileMap).toBe('object'); expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0); - expect(profile.onAddRulesProfile).toBeUndefined(); - expect(profile.onRemoveRulesProfile).toBeUndefined(); - expect(profile.onPostConvertRulesProfile).toBeUndefined(); + // No explicit lifecycle hooks - uses declarative slashCommands + // (slashCommands may be null if @tm/profiles lookup fails in test env) } ); }); diff --git a/tests/unit/profiles/rule-transformer-gemini.test.js b/tests/unit/profiles/rule-transformer-gemini.test.js index 82c204d4..edea18e2 100644 --- a/tests/unit/profiles/rule-transformer-gemini.test.js +++ b/tests/unit/profiles/rule-transformer-gemini.test.js @@ -21,8 +21,15 @@ describe('Rule Transformer - Gemini Profile', () => { }); }); - test('should have minimal profile implementation', () => { - // Verify that gemini.js is minimal (no lifecycle functions) + test('should have declarative slashCommands config', () => { + // Gemini has a slash command profile in @tm/profiles, so base-profile.js + // includes a declarative slashCommands config (execution handled by rule-transformer) + expect(geminiProfile.slashCommands).toBeDefined(); + expect(geminiProfile.slashCommands.profile).toBeDefined(); + expect(geminiProfile.slashCommands.profile.supportsCommands).toBe(true); + expect(geminiProfile.slashCommands.commands).toBeDefined(); + expect(Array.isArray(geminiProfile.slashCommands.commands)).toBe(true); + // Gemini doesn't define custom lifecycle hooks expect(geminiProfile.onAddRulesProfile).toBeUndefined(); expect(geminiProfile.onRemoveRulesProfile).toBeUndefined(); expect(geminiProfile.onPostConvertRulesProfile).toBeUndefined();