chore: remove MCP code (to new build new one from scratch)
This commit is contained in:
parent
f88fa9cb09
commit
919d40b7bc
38 changed files with 2 additions and 2242 deletions
2
mcp-server/.gitignore
vendored
Normal file
2
mcp-server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
node_modules/
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
# @claude-team/mcp-server
|
||||
|
||||
**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.**
|
||||
|
||||
Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui)
|
||||
- [Quick start](#quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Tools Reference](#tools-reference)
|
||||
- [Data Storage](#data-storage)
|
||||
- [Development](#development)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which:
|
||||
|
||||
- Stores task data as JSON under `~/.claude/tasks/{teamName}/`
|
||||
- Stores team config under `~/.claude/teams/{teamName}/`
|
||||
- Is installed automatically when you run Claude Agent Teams UI at least once
|
||||
|
||||
### Use cases
|
||||
|
||||
| Scenario | Description |
|
||||
|----------|-------------|
|
||||
| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat |
|
||||
| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use |
|
||||
| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐
|
||||
│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │
|
||||
│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │
|
||||
│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │
|
||||
└─────────────────────┘ └──────────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Claude Agent Teams UI
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. |
|
||||
| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. |
|
||||
| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. |
|
||||
|
||||
Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`)
|
||||
2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build`
|
||||
3. Add to Cursor MCP config (`~/.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Restart Cursor. The 13 tools will appear for the AI assistant.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20+**
|
||||
- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`)
|
||||
|
||||
If `teamctl.js` is missing, the server throws at startup:
|
||||
|
||||
```
|
||||
teamctl.js not found at ~/.claude/tools/teamctl.js.
|
||||
Make sure Claude Agent Teams UI has been run at least once,
|
||||
or set the TEAMCTL_PATH environment variable.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From the monorepo (development)
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### As a dependency
|
||||
|
||||
```bash
|
||||
pnpm add @claude-team/mcp-server
|
||||
# or
|
||||
npm install @claude-team/mcp-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With global install (`pnpm link` or `npm link`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "team-mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` |
|
||||
|
||||
---
|
||||
|
||||
## Tools Reference
|
||||
|
||||
All tools require a `team` parameter (team name, folder under `~/.claude/teams/`).
|
||||
|
||||
### Task CRUD
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` |
|
||||
| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) |
|
||||
| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) |
|
||||
| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals |
|
||||
| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` |
|
||||
|
||||
### Task collaboration
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` |
|
||||
| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected |
|
||||
| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board |
|
||||
| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB |
|
||||
|
||||
### Kanban board
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` |
|
||||
| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` |
|
||||
|
||||
### Code review
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) |
|
||||
|
||||
### Messaging
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications |
|
||||
|
||||
---
|
||||
|
||||
## Data Storage
|
||||
|
||||
| Location | Contents |
|
||||
|----------|----------|
|
||||
| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) |
|
||||
| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes |
|
||||
|
||||
Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm build` | Build with tsup → `dist/index.js` |
|
||||
| `pnpm dev` | Run with tsx (no build) |
|
||||
| `pnpm test` | Run Vitest tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm typecheck` | TypeScript check |
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
mcp-server/
|
||||
├── src/
|
||||
│ ├── index.ts # FastMCP server, registers all tools
|
||||
│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess
|
||||
│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout
|
||||
│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.)
|
||||
│ └── tools/
|
||||
│ ├── index.ts # registerAllTools()
|
||||
│ ├── task-create.ts
|
||||
│ ├── task-get.ts
|
||||
│ ├── ...
|
||||
│ └── message-send.ts
|
||||
├── test/
|
||||
│ ├── tools/ # Per-tool tests
|
||||
│ ├── teamctl-runner.test.ts
|
||||
│ ├── output-parser.test.ts
|
||||
│ └── schemas.test.ts
|
||||
├── package.json
|
||||
├── tsup.config.ts
|
||||
└── vitest.config.ts
|
||||
```
|
||||
|
||||
### Adding a new tool
|
||||
|
||||
1. Create `src/tools/your-tool.ts` with `register(server, runner)`
|
||||
2. Add to `ALL_TOOLS` in `src/tools/index.ts`
|
||||
3. Add Zod parameters and map to `teamctl` CLI args
|
||||
4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts`
|
||||
5. Add tests in `test/tools/your-tool.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required):
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` |
|
||||
| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes |
|
||||
| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it |
|
||||
| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project: [AGPL-3.0](../LICENSE).
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { FastMCP } from 'fastmcp';
|
||||
import { TeamctlRunner } from './teamctl-runner.js';
|
||||
import { registerAllTools } from './tools/index.js';
|
||||
|
||||
const server = new FastMCP({
|
||||
name: 'claude-team-tools',
|
||||
version: '1.0.0',
|
||||
instructions: `MCP server for managing Claude Agent Teams kanban board and tasks.
|
||||
|
||||
Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging.
|
||||
All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI.
|
||||
|
||||
Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`,
|
||||
});
|
||||
|
||||
const runner = new TeamctlRunner();
|
||||
|
||||
registerAllTools(server, runner);
|
||||
|
||||
server.start({ transportType: 'stdio' });
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* Parses teamctl stdout into structured results.
|
||||
*
|
||||
* teamctl outputs in three formats:
|
||||
* 1. JSON — task create/get/list, attach, message send, kanban reviewers list
|
||||
* 2. "OK ..." text — status changes, comments, links, kanban moves, reviews
|
||||
* 3. Plain text — task briefing (multi-line human-readable report)
|
||||
*/
|
||||
|
||||
/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */
|
||||
export function parseJsonOutput<T = unknown>(stdout: string): T {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Empty output from teamctl (expected JSON)');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse "OK ..." acknowledgment lines from teamctl */
|
||||
export function parseOkOutput(stdout: string): string {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.startsWith('OK ')) {
|
||||
return trimmed.slice(3); // Strip "OK " prefix, keep the rest
|
||||
}
|
||||
// Some commands output just "OK\n"
|
||||
if (trimmed === 'OK') {
|
||||
return 'OK';
|
||||
}
|
||||
// Return as-is if format is unexpected — don't throw
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Return plain text as-is (briefing, help output) */
|
||||
export function parseTextOutput(stdout: string): string {
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format teamctl stderr into a user-friendly error message.
|
||||
* teamctl writes errors to stderr via `die(message)` and exits with code 1.
|
||||
*/
|
||||
export function formatError(stderr: string, stdout: string): string {
|
||||
const msg = stderr.trim() || stdout.trim();
|
||||
if (!msg) return 'Unknown teamctl error';
|
||||
return msg;
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { sep, isAbsolute } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Matches teamctl's `isSafePathSegment()`:
|
||||
* rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..'
|
||||
*/
|
||||
const safePathSegment = (label: string) =>
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.trim().length > 0 &&
|
||||
v !== '.' &&
|
||||
v !== '..' &&
|
||||
!v.includes('/') &&
|
||||
!v.includes('\\') &&
|
||||
!v.includes('\0') &&
|
||||
!v.includes('..'),
|
||||
{ message: `Invalid ${label}: must be a safe path segment` },
|
||||
);
|
||||
|
||||
/** Team name — folder inside `~/.claude/teams/` */
|
||||
export const teamNameSchema = safePathSegment('team name').describe(
|
||||
'Team name (folder in ~/.claude/teams/)',
|
||||
);
|
||||
|
||||
/** Numeric task ID produced by teamctl's highwatermark counter */
|
||||
export const taskIdSchema = z
|
||||
.string()
|
||||
.regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")')
|
||||
.describe('Numeric task ID');
|
||||
|
||||
/** Team member name — folder inside inboxes, safe path segment */
|
||||
export const memberNameSchema = safePathSegment('member name').describe(
|
||||
'Team member name',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums — match teamctl's normalizeStatus / normalizeColumn / etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const taskStatusSchema = z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
]);
|
||||
|
||||
export const kanbanColumnSchema = z
|
||||
.enum(['review', 'approved'])
|
||||
.describe('Kanban column to move task to');
|
||||
|
||||
export const clarificationSchema = z
|
||||
.enum(['lead', 'user', 'clear'])
|
||||
.describe('Who needs to clarify: lead, user, or clear the flag');
|
||||
|
||||
export const linkTypeSchema = z
|
||||
.enum(['blocked-by', 'blocks', 'related'])
|
||||
.describe('Relationship type between tasks');
|
||||
|
||||
export const linkOperationSchema = z.enum(['link', 'unlink']);
|
||||
|
||||
export const reviewDecisionSchema = z.enum(['approve', 'request-changes']);
|
||||
|
||||
export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Comma-separated task IDs sent as a single CLI argument */
|
||||
export const taskIdsArraySchema = z
|
||||
.array(taskIdSchema)
|
||||
.describe('Array of task IDs (e.g. ["1", "3"])');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File / attachment schemas — defence-in-depth for CLI arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Absolute file path without traversal sequences */
|
||||
export const filePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((p) => isAbsolute(p), { message: 'Path must be absolute' })
|
||||
.refine((p) => !p.split(sep).includes('..'), {
|
||||
message: 'Path must not contain traversal sequences (..)',
|
||||
})
|
||||
.refine((p) => !p.includes('\0'), {
|
||||
message: 'Path must not contain null bytes',
|
||||
});
|
||||
|
||||
/** Safe filename — no path separators, no null bytes, reasonable length */
|
||||
export const safeFilenameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine(
|
||||
(f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'),
|
||||
{ message: 'Filename must not contain path separators or null bytes' },
|
||||
);
|
||||
|
||||
/** MIME type — standard type/subtype format */
|
||||
export const mimeTypeSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/,
|
||||
'Invalid MIME type format (expected type/subtype)',
|
||||
);
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TeamctlResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface ITeamctlRunner {
|
||||
execute(args: string[]): Promise<TeamctlResult>;
|
||||
}
|
||||
|
||||
export interface TeamctlRunnerOptions {
|
||||
/** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */
|
||||
teamctlPath?: string;
|
||||
/** Max concurrent subprocess calls (default: 5) */
|
||||
maxConcurrent?: number;
|
||||
/** Subprocess timeout in ms (default: 10 000) */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semaphore — limits concurrent subprocess spawns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.current < this.max) {
|
||||
this.current++;
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(() => {
|
||||
this.current++;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.current--;
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamctlRunner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TeamctlRunner implements ITeamctlRunner {
|
||||
readonly teamctlPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly semaphore: Semaphore;
|
||||
|
||||
constructor(options?: TeamctlRunnerOptions) {
|
||||
this.teamctlPath = resolveTeamctlPath(options?.teamctlPath);
|
||||
this.timeoutMs = options?.timeoutMs ?? 10_000;
|
||||
this.semaphore = new Semaphore(options?.maxConcurrent ?? 5);
|
||||
|
||||
// Fail fast if teamctl.js doesn't exist
|
||||
if (!existsSync(this.teamctlPath)) {
|
||||
throw new Error(
|
||||
`teamctl.js not found at ${this.teamctlPath}. ` +
|
||||
'Make sure Claude Agent Teams UI has been run at least once, ' +
|
||||
'or set the TEAMCTL_PATH environment variable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args: string[]): Promise<TeamctlResult> {
|
||||
await this.semaphore.acquire();
|
||||
try {
|
||||
return await this.spawn(args);
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
private spawn(args: string[]): Promise<TeamctlResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [this.teamctlPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: this.timeoutMs,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
|
||||
let settled = false;
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`Failed to spawn teamctl: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
||||
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
||||
|
||||
if (signal === 'SIGTERM') {
|
||||
const partial = stdout.slice(0, 500) || stderr.slice(0, 500);
|
||||
reject(
|
||||
new Error(
|
||||
`teamctl timed out after ${this.timeoutMs}ms` +
|
||||
(partial ? `. Partial output: ${partial}` : ''),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveTeamctlPath(explicit?: string): string {
|
||||
if (explicit) return explicit;
|
||||
|
||||
const fromEnv = process.env['TEAMCTL_PATH'];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
// Default: ~/.claude/tools/teamctl.js
|
||||
return join(homedir(), '.claude', 'tools', 'teamctl.js');
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
|
||||
import { register as taskCreate } from './task-create.js';
|
||||
import { register as taskSetStatus } from './task-set-status.js';
|
||||
import { register as taskSetOwner } from './task-set-owner.js';
|
||||
import { register as taskGet } from './task-get.js';
|
||||
import { register as taskList } from './task-list.js';
|
||||
import { register as taskComment } from './task-comment.js';
|
||||
import { register as taskLink } from './task-link.js';
|
||||
import { register as taskBriefing } from './task-briefing.js';
|
||||
import { register as taskAttach } from './task-attach.js';
|
||||
import { register as kanbanMove } from './kanban-move.js';
|
||||
import { register as kanbanReviewers } from './kanban-reviewers.js';
|
||||
import { register as reviewAction } from './review-action.js';
|
||||
import { register as messageSend } from './message-send.js';
|
||||
|
||||
const ALL_TOOLS = [
|
||||
taskCreate,
|
||||
taskSetStatus,
|
||||
taskSetOwner,
|
||||
taskGet,
|
||||
taskList,
|
||||
taskComment,
|
||||
taskLink,
|
||||
taskBriefing,
|
||||
taskAttach,
|
||||
kanbanMove,
|
||||
kanbanReviewers,
|
||||
reviewAction,
|
||||
messageSend,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Register all 13 MCP tools with the server.
|
||||
* Each tool wraps a teamctl CLI command via the runner.
|
||||
*/
|
||||
export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
for (const register of ALL_TOOLS) {
|
||||
register(server, runner);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'kanban_move',
|
||||
description: `Move a task to a kanban column or clear it from the kanban board.
|
||||
|
||||
Columns: "review" (awaiting code review) or "approved" (review passed).
|
||||
Use operation "clear" to remove a task from the kanban board (returns to status-based display).
|
||||
Moving to a kanban column also sets the task status to "completed".`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'),
|
||||
column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
let cliArgs: string[];
|
||||
|
||||
if (args.operation === 'set-column') {
|
||||
if (!args.column) {
|
||||
throw new UserError('column is required when operation is "set-column"');
|
||||
}
|
||||
cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column];
|
||||
} else {
|
||||
cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id];
|
||||
}
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput, parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'kanban_reviewers',
|
||||
description: `Manage the kanban board's reviewer list.
|
||||
|
||||
Operations:
|
||||
- "list": returns JSON array of reviewer names
|
||||
- "add": adds a reviewer (name required)
|
||||
- "remove": removes a reviewer (name required)`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
operation: reviewerOperationSchema.describe('"list", "add", or "remove"'),
|
||||
name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
if (args.operation !== 'list' && !args.name) {
|
||||
throw new UserError(`name is required for "${args.operation}" operation`);
|
||||
}
|
||||
|
||||
const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation];
|
||||
if (args.name) cliArgs.push(args.name);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
|
||||
// "list" returns JSON array, "add"/"remove" return "OK ..." text
|
||||
if (args.operation === 'list') {
|
||||
return parseJsonOutput(result.stdout);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'message_send',
|
||||
description: `Send an inbox message to a team member. Returns delivery confirmation JSON.
|
||||
|
||||
Messages appear in the member's inbox and can trigger notifications.
|
||||
The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
to: memberNameSchema.describe('Recipient member name'),
|
||||
text: z.string().min(1).max(10000).describe('Message text'),
|
||||
summary: z.string().max(200).optional().describe('Short summary for notification preview'),
|
||||
from: memberNameSchema.optional().describe('Sender name'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text];
|
||||
|
||||
if (args.summary) cliArgs.push('--summary', args.summary);
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'review_action',
|
||||
description: `Approve a task or request changes.
|
||||
|
||||
- "approve": marks the task as approved in kanban, optionally posts a review comment
|
||||
- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
decision: reviewDecisionSchema.describe('"approve" or "request-changes"'),
|
||||
comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'),
|
||||
from: memberNameSchema.optional().describe('Reviewer name'),
|
||||
notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
if (args.decision === 'request-changes' && !args.comment) {
|
||||
throw new UserError('comment is required when requesting changes');
|
||||
}
|
||||
|
||||
const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id];
|
||||
|
||||
// approve uses --note for optional comment; request-changes uses --comment
|
||||
if (args.decision === 'request-changes' && args.comment) {
|
||||
cliArgs.push('--comment', args.comment);
|
||||
} else if (args.decision === 'approve' && args.comment) {
|
||||
cliArgs.push('--note', args.comment);
|
||||
}
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
if (args.notify_owner) cliArgs.push('--notify-owner');
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_attach',
|
||||
description: `Attach a file to a task. Returns attachment metadata JSON.
|
||||
|
||||
Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
file: filePathSchema.describe('Absolute path to the file to attach'),
|
||||
filename: safeFilenameSchema.optional().describe('Override stored filename'),
|
||||
mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'),
|
||||
mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'),
|
||||
from: memberNameSchema.optional().describe('Uploader name'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file];
|
||||
|
||||
if (args.filename) cliArgs.push('--filename', args.filename);
|
||||
if (args.mime_type) cliArgs.push('--mime-type', args.mime_type);
|
||||
if (args.mode) cliArgs.push('--mode', args.mode);
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseTextOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_briefing',
|
||||
description: `Generate a text briefing for a team member showing their assigned tasks vs the team board.
|
||||
|
||||
Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
member: memberNameSchema.describe('Member name to generate briefing for'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseTextOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_comment',
|
||||
description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
text: z.string().min(1).max(10000).describe('Comment text'),
|
||||
from: memberNameSchema.optional().describe('Author name (skips self-notification)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text];
|
||||
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
description: `Create a new task in a team's task board. Returns the created task JSON.
|
||||
|
||||
Behavior:
|
||||
- If owner is set and no blockers → status defaults to "in_progress"
|
||||
- If blocked_by specified → status defaults to "pending" (even with owner)
|
||||
- If notify is true, sends an inbox notification to the assigned owner`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
subject: z.string().min(1).max(500).describe('Task title'),
|
||||
description: z.string().max(5000).optional().describe('Detailed task description'),
|
||||
owner: memberNameSchema.optional().describe('Assign to a team member'),
|
||||
blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'),
|
||||
related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'),
|
||||
status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'),
|
||||
active_form: z.string().max(200).optional().describe('Active form hint for CLI display'),
|
||||
notify: z.boolean().optional().describe('Send inbox notification to owner'),
|
||||
from: memberNameSchema.optional().describe('Author name for notifications'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject];
|
||||
|
||||
if (args.description) cliArgs.push('--description', args.description);
|
||||
if (args.owner) cliArgs.push('--owner', args.owner);
|
||||
if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(','));
|
||||
if (args.related?.length) cliArgs.push('--related', args.related.join(','));
|
||||
if (args.status) cliArgs.push('--status', args.status);
|
||||
if (args.active_form) cliArgs.push('--activeForm', args.active_form);
|
||||
if (args.notify) cliArgs.push('--notify');
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_get',
|
||||
description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'get', args.task_id];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import {
|
||||
teamNameSchema,
|
||||
taskIdSchema,
|
||||
linkTypeSchema,
|
||||
linkOperationSchema,
|
||||
} from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_link',
|
||||
description: `Link or unlink task dependencies.
|
||||
|
||||
Relationship types:
|
||||
- "blocked-by": this task is blocked by the target
|
||||
- "blocks": this task blocks the target
|
||||
- "related": non-blocking relationship
|
||||
|
||||
Links are bidirectional: linking A blocked-by B also sets B blocks A.
|
||||
Circular dependencies are detected and rejected.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'),
|
||||
relationship: linkTypeSchema,
|
||||
target_id: taskIdSchema.describe('The other task ID to link/unlink'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = [
|
||||
'--team', args.team,
|
||||
'task', args.operation,
|
||||
args.task_id,
|
||||
`--${args.relationship}`, args.target_id,
|
||||
];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_list',
|
||||
description: `List all tasks for a team. Returns a JSON array of task objects.
|
||||
|
||||
Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'list'];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_set_owner',
|
||||
description: `Assign a task to a team member or clear the assignment.
|
||||
|
||||
Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'),
|
||||
notify: z.boolean().optional().describe('Send inbox notification to new owner'),
|
||||
from: memberNameSchema.optional().describe('Author name for notification'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner];
|
||||
|
||||
if (args.notify) cliArgs.push('--notify');
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_set_status',
|
||||
description: `Change the status of a task.
|
||||
|
||||
Valid transitions: pending → in_progress → completed → deleted (and back).
|
||||
Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete".
|
||||
Records status history and tracks work intervals (time spent in_progress).`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
status: taskStatusSchema.describe('New status for the task'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJsonOutput,
|
||||
parseOkOutput,
|
||||
parseTextOutput,
|
||||
formatError,
|
||||
} from '../src/output-parser.js';
|
||||
|
||||
describe('parseJsonOutput', () => {
|
||||
it('parses valid JSON object', () => {
|
||||
const input = '{"id":"42","subject":"Fix bug"}\n';
|
||||
expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' });
|
||||
});
|
||||
|
||||
it('parses valid JSON array', () => {
|
||||
const input = '[{"id":"1"},{"id":"2"}]\n';
|
||||
expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
const input = ' \n {"ok":true} \n ';
|
||||
expect(parseJsonOutput(input)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('throws on empty output', () => {
|
||||
expect(() => parseJsonOutput('')).toThrow('Empty output');
|
||||
expect(() => parseJsonOutput(' \n ')).toThrow('Empty output');
|
||||
});
|
||||
|
||||
it('throws on invalid JSON', () => {
|
||||
expect(() => parseJsonOutput('not json')).toThrow('Failed to parse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOkOutput', () => {
|
||||
it('strips "OK " prefix', () => {
|
||||
expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed');
|
||||
});
|
||||
|
||||
it('handles bare "OK"', () => {
|
||||
expect(parseOkOutput('OK\n')).toBe('OK');
|
||||
});
|
||||
|
||||
it('returns as-is for unexpected format', () => {
|
||||
expect(parseOkOutput('Something else')).toBe('Something else');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextOutput', () => {
|
||||
it('trims and returns text', () => {
|
||||
const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n';
|
||||
expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(parseTextOutput('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatError', () => {
|
||||
it('uses stderr when available', () => {
|
||||
expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42');
|
||||
});
|
||||
|
||||
it('falls back to stdout', () => {
|
||||
expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error');
|
||||
});
|
||||
|
||||
it('returns default for empty', () => {
|
||||
expect(formatError('', '')).toBe('Unknown teamctl error');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
teamNameSchema,
|
||||
taskIdSchema,
|
||||
memberNameSchema,
|
||||
taskStatusSchema,
|
||||
kanbanColumnSchema,
|
||||
clarificationSchema,
|
||||
linkTypeSchema,
|
||||
linkOperationSchema,
|
||||
reviewDecisionSchema,
|
||||
reviewerOperationSchema,
|
||||
taskIdsArraySchema,
|
||||
filePathSchema,
|
||||
safeFilenameSchema,
|
||||
mimeTypeSchema,
|
||||
} from '../src/schemas.js';
|
||||
|
||||
describe('teamNameSchema', () => {
|
||||
it('accepts valid team names', () => {
|
||||
expect(teamNameSchema.parse('acme')).toBe('acme');
|
||||
expect(teamNameSchema.parse('my-team')).toBe('my-team');
|
||||
expect(teamNameSchema.parse('My_Team')).toBe('My_Team');
|
||||
expect(teamNameSchema.parse('team123')).toBe('team123');
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => teamNameSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => teamNameSchema.parse('..')).toThrow();
|
||||
expect(() => teamNameSchema.parse('.')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a/b')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a\\b')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a..b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => teamNameSchema.parse('a\0b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskIdSchema', () => {
|
||||
it('accepts numeric IDs', () => {
|
||||
expect(taskIdSchema.parse('1')).toBe('1');
|
||||
expect(taskIdSchema.parse('42')).toBe('42');
|
||||
expect(taskIdSchema.parse('1234567890')).toBe('1234567890');
|
||||
});
|
||||
|
||||
it('accepts zero', () => {
|
||||
expect(taskIdSchema.parse('0')).toBe('0');
|
||||
});
|
||||
|
||||
it('accepts leading zeros', () => {
|
||||
expect(taskIdSchema.parse('007')).toBe('007');
|
||||
});
|
||||
|
||||
it('rejects non-numeric', () => {
|
||||
expect(() => taskIdSchema.parse('abc')).toThrow();
|
||||
expect(() => taskIdSchema.parse('')).toThrow();
|
||||
expect(() => taskIdSchema.parse('1.5')).toThrow();
|
||||
expect(() => taskIdSchema.parse('-1')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => taskIdSchema.parse('12345678901')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memberNameSchema', () => {
|
||||
it('accepts valid member names', () => {
|
||||
expect(memberNameSchema.parse('alice')).toBe('alice');
|
||||
expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith');
|
||||
expect(memberNameSchema.parse('user_1')).toBe('user_1');
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => memberNameSchema.parse('..')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a/b')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a\\b')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a..b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => memberNameSchema.parse('a\0b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => memberNameSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum schemas', () => {
|
||||
it('taskStatusSchema accepts valid values', () => {
|
||||
expect(taskStatusSchema.parse('pending')).toBe('pending');
|
||||
expect(taskStatusSchema.parse('in_progress')).toBe('in_progress');
|
||||
expect(taskStatusSchema.parse('completed')).toBe('completed');
|
||||
expect(taskStatusSchema.parse('deleted')).toBe('deleted');
|
||||
expect(() => taskStatusSchema.parse('invalid')).toThrow();
|
||||
});
|
||||
|
||||
it('kanbanColumnSchema accepts valid values', () => {
|
||||
expect(kanbanColumnSchema.parse('review')).toBe('review');
|
||||
expect(kanbanColumnSchema.parse('approved')).toBe('approved');
|
||||
expect(() => kanbanColumnSchema.parse('todo')).toThrow();
|
||||
expect(() => kanbanColumnSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('clarificationSchema accepts valid values', () => {
|
||||
expect(clarificationSchema.parse('lead')).toBe('lead');
|
||||
expect(clarificationSchema.parse('user')).toBe('user');
|
||||
expect(clarificationSchema.parse('clear')).toBe('clear');
|
||||
expect(() => clarificationSchema.parse('nobody')).toThrow();
|
||||
});
|
||||
|
||||
it('linkTypeSchema accepts valid values', () => {
|
||||
expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by');
|
||||
expect(linkTypeSchema.parse('blocks')).toBe('blocks');
|
||||
expect(linkTypeSchema.parse('related')).toBe('related');
|
||||
});
|
||||
|
||||
it('linkOperationSchema accepts valid values', () => {
|
||||
expect(linkOperationSchema.parse('link')).toBe('link');
|
||||
expect(linkOperationSchema.parse('unlink')).toBe('unlink');
|
||||
});
|
||||
|
||||
it('reviewDecisionSchema accepts valid values', () => {
|
||||
expect(reviewDecisionSchema.parse('approve')).toBe('approve');
|
||||
expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes');
|
||||
});
|
||||
|
||||
it('reviewerOperationSchema accepts valid values', () => {
|
||||
expect(reviewerOperationSchema.parse('list')).toBe('list');
|
||||
expect(reviewerOperationSchema.parse('add')).toBe('add');
|
||||
expect(reviewerOperationSchema.parse('remove')).toBe('remove');
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskIdsArraySchema', () => {
|
||||
it('accepts valid arrays', () => {
|
||||
expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||
expect(taskIdsArraySchema.parse([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects arrays with invalid IDs', () => {
|
||||
expect(() => taskIdsArraySchema.parse(['abc'])).toThrow();
|
||||
expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filePathSchema', () => {
|
||||
it('accepts absolute paths', () => {
|
||||
expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt');
|
||||
expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf');
|
||||
});
|
||||
|
||||
it('rejects relative paths', () => {
|
||||
expect(() => filePathSchema.parse('relative/path.txt')).toThrow();
|
||||
expect(() => filePathSchema.parse('./file.txt')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow();
|
||||
expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => filePathSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeFilenameSchema', () => {
|
||||
it('accepts valid filenames', () => {
|
||||
expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf');
|
||||
expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz');
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(() => safeFilenameSchema.parse('../../evil')).toThrow();
|
||||
expect(() => safeFilenameSchema.parse('dir/file')).toThrow();
|
||||
expect(() => safeFilenameSchema.parse('dir\\file')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => safeFilenameSchema.parse('file\0name')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mimeTypeSchema', () => {
|
||||
it('accepts valid MIME types', () => {
|
||||
expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf');
|
||||
expect(mimeTypeSchema.parse('image/png')).toBe('image/png');
|
||||
expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain');
|
||||
expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('rejects invalid formats', () => {
|
||||
expect(() => mimeTypeSchema.parse('invalid')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('/pdf')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('application/')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TeamctlRunner } from '../src/teamctl-runner.js';
|
||||
import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js';
|
||||
|
||||
// We can't easily test the real subprocess without teamctl.js installed,
|
||||
// so we test the interface contract and error handling.
|
||||
|
||||
describe('TeamctlRunner', () => {
|
||||
it('throws if teamctl.js does not exist', () => {
|
||||
expect(
|
||||
() => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }),
|
||||
).toThrow('teamctl.js not found');
|
||||
});
|
||||
|
||||
it('resolves path from TEAMCTL_PATH env', () => {
|
||||
const original = process.env['TEAMCTL_PATH'];
|
||||
try {
|
||||
process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js';
|
||||
// Will throw because file doesn't exist, but we can check the error message
|
||||
expect(
|
||||
() => new TeamctlRunner(),
|
||||
).toThrow('/tmp/test-teamctl.js');
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env['TEAMCTL_PATH'] = original;
|
||||
} else {
|
||||
delete process.env['TEAMCTL_PATH'];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mock runner for tool tests
|
||||
export function createMockRunner(
|
||||
responses: Map<string, TeamctlResult> | TeamctlResult,
|
||||
): ITeamctlRunner {
|
||||
return {
|
||||
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
|
||||
if (responses instanceof Map) {
|
||||
const key = args.join(' ');
|
||||
const result = responses.get(key);
|
||||
if (result) return result;
|
||||
// Fallback: check if any key is a prefix
|
||||
for (const [k, v] of responses) {
|
||||
if (key.startsWith(k)) return v;
|
||||
}
|
||||
return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 };
|
||||
}
|
||||
return responses;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ITeamctlRunner interface', () => {
|
||||
it('mock runner returns success', async () => {
|
||||
const runner = createMockRunner({
|
||||
stdout: '{"id":"1","subject":"Test"}\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await runner.execute(['--team', 'test', 'task', 'create']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toHaveProperty('id', '1');
|
||||
});
|
||||
|
||||
it('mock runner returns error', async () => {
|
||||
const runner = createMockRunner({
|
||||
stdout: '',
|
||||
stderr: 'Task not found: #99\n',
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(['--team', 'test', 'task', 'get', '99']);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Task not found');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/kanban-move.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('kanban_move', () => {
|
||||
function setup(response = ok('OK kanban #1 column=review\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('kanban_move')! };
|
||||
}
|
||||
|
||||
it('builds set-column CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'set-column', '1', 'review',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds clear CLI args', async () => {
|
||||
const { runner, tool } = setup(ok('OK kanban #1 cleared\n'));
|
||||
await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'clear', '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws UserError when set-column called without column', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }),
|
||||
).rejects.toThrow('column is required');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }),
|
||||
).rejects.toThrow('Failed to update kanban');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/kanban-reviewers.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('kanban_reviewers', () => {
|
||||
function setup(response = ok('["alice","bob"]')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('kanban_reviewers')! };
|
||||
}
|
||||
|
||||
it('builds list CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', operation: 'list' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'reviewers', 'list',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns JSON array for list', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', operation: 'list' });
|
||||
expect(result).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('builds add CLI args with name', async () => {
|
||||
const { runner, tool } = setup(ok('OK reviewer added\n'));
|
||||
await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns OK text for add/remove', async () => {
|
||||
const { tool } = setup(ok('OK reviewer added\n'));
|
||||
const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
|
||||
expect(result).toBe('reviewer added');
|
||||
});
|
||||
|
||||
it('throws UserError when add called without name', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'add' }),
|
||||
).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('throws UserError when remove called without name', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'remove' }),
|
||||
).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('reviewer not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }),
|
||||
).rejects.toThrow('Failed to manage reviewers');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/message-send.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('message_send', () => {
|
||||
const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}';
|
||||
|
||||
function setup(response = ok(deliveryJson)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('message_send')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes summary and from flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', to: 'alice', text: 'Task done',
|
||||
summary: 'Completed task #1', from: 'bob',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--summary');
|
||||
expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1');
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('bob');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
|
||||
expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('recipient inbox not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }),
|
||||
).rejects.toThrow('Failed to send message');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { registerAllTools } from '../../src/tools/index.js';
|
||||
import { createMockRunner, createMockServer } from './test-helpers.js';
|
||||
|
||||
describe('registerAllTools', () => {
|
||||
it('registers exactly 13 tools', () => {
|
||||
const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const { server, tools } = createMockServer();
|
||||
registerAllTools(server, runner);
|
||||
expect(tools.size).toBe(13);
|
||||
});
|
||||
|
||||
it('registers all expected tool names', () => {
|
||||
const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const { server, tools } = createMockServer();
|
||||
registerAllTools(server, runner);
|
||||
|
||||
const expected = [
|
||||
'task_create', 'task_set_status', 'task_set_owner',
|
||||
'task_get', 'task_list', 'task_comment', 'task_link',
|
||||
'task_briefing', 'task_attach', 'kanban_move',
|
||||
'kanban_reviewers', 'review_action', 'message_send',
|
||||
];
|
||||
for (const name of expected) {
|
||||
expect(tools.has(name), `missing tool: ${name}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/review-action.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('review_action', () => {
|
||||
function setup(response = ok('OK review #1 approved\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('review_action')! };
|
||||
}
|
||||
|
||||
it('builds approve CLI args (no comment)', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'review', 'approve', '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds approve CLI args with --note (not --comment)', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
// approve uses --note, NOT --comment
|
||||
expect(args).toContain('--note');
|
||||
expect(args[args.indexOf('--note') + 1]).toBe('LGTM');
|
||||
expect(args).not.toContain('--comment');
|
||||
});
|
||||
|
||||
it('builds request-changes CLI args with --comment (not --note)', async () => {
|
||||
const { runner, tool } = setup(ok('OK review #1 requested changes\n'));
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--comment');
|
||||
expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests');
|
||||
expect(args).not.toContain('--note');
|
||||
});
|
||||
|
||||
it('throws when request-changes has no comment', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }),
|
||||
).rejects.toThrow('comment is required when requesting changes');
|
||||
});
|
||||
|
||||
it('includes from and notify_owner flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', decision: 'approve',
|
||||
from: 'alice', notify_owner: true,
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--from');
|
||||
expect(args).toContain('--notify-owner');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not in review'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }),
|
||||
).rejects.toThrow('Failed to approve');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-attach.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_attach', () => {
|
||||
const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}';
|
||||
|
||||
function setup(response = ok(attachJson)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_attach')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all optional flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', file: '/tmp/file.pdf',
|
||||
filename: 'renamed.pdf', mime_type: 'application/pdf',
|
||||
mode: 'link', from: 'alice',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--filename');
|
||||
expect(args).toContain('--mime-type');
|
||||
expect(args).toContain('--mode');
|
||||
expect(args).toContain('--from');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
|
||||
expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('file too large'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }),
|
||||
).rejects.toThrow('Failed to attach file');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-briefing.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_briefing', () => {
|
||||
const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n';
|
||||
|
||||
function setup(response = ok(briefingText)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_briefing')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', member: 'alice' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'briefing', '--for', 'alice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns trimmed plain text', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', member: 'alice' });
|
||||
expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('member not found'));
|
||||
await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-comment.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_comment', () => {
|
||||
function setup(response = ok('OK comment added to task #1\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_comment')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes from flag', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('alice');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not found'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-create.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_create', () => {
|
||||
function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_create')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', subject: 'Fix bug' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'create', '--subject', 'Fix bug',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all optional flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme',
|
||||
subject: 'Big task',
|
||||
description: 'Details here',
|
||||
owner: 'alice',
|
||||
blocked_by: ['1', '2'],
|
||||
related: ['3'],
|
||||
status: 'pending',
|
||||
active_form: 'Fixing bug',
|
||||
notify: true,
|
||||
from: 'bob',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--description');
|
||||
expect(args).toContain('--owner');
|
||||
expect(args).toContain('--blocked-by');
|
||||
expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2');
|
||||
expect(args).toContain('--related');
|
||||
expect(args[args.indexOf('--related') + 1]).toBe('3');
|
||||
expect(args).toContain('--status');
|
||||
expect(args).toContain('--activeForm');
|
||||
expect(args).toContain('--notify');
|
||||
expect(args).toContain('--from');
|
||||
});
|
||||
|
||||
it('skips empty blocked_by array', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).not.toContain('--blocked-by');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', subject: 'Fix bug' });
|
||||
expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('team not found'));
|
||||
await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-get.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_get', () => {
|
||||
function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_get')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '42' });
|
||||
expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']);
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '42' });
|
||||
expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('Task not found: #99'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-link.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_link', () => {
|
||||
function setup(response = ok('OK task #1 blocked-by #2\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_link')! };
|
||||
}
|
||||
|
||||
it('builds link CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'link',
|
||||
relationship: 'blocked-by', target_id: '2',
|
||||
});
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'link', '1', '--blocked-by', '2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds unlink CLI args', async () => {
|
||||
const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n'));
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'unlink',
|
||||
relationship: 'related', target_id: '3',
|
||||
});
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'unlink', '1', '--related', '3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('circular dependency'));
|
||||
await expect(
|
||||
tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'link',
|
||||
relationship: 'blocked-by', target_id: '1',
|
||||
}),
|
||||
).rejects.toThrow('Failed to link tasks');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-list.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_list', () => {
|
||||
function setup(response = ok('[{"id":"1"},{"id":"2"}]')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_list')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme' });
|
||||
expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']);
|
||||
});
|
||||
|
||||
it('returns parsed JSON array', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('team dir not found'));
|
||||
await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-set-owner.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_set_owner', () => {
|
||||
function setup(response = ok('OK task #1 owner=alice\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_set_owner')! };
|
||||
}
|
||||
|
||||
it('builds args for assignment', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-owner', '1', 'alice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds args for clear', async () => {
|
||||
const { runner, tool } = setup(ok('OK task #1 owner=cleared\n'));
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-owner', '1', 'clear',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes notify and from flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--notify');
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('bob');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('member not found'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-set-status.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_set_status', () => {
|
||||
function setup(response = ok('OK task #1 status=completed\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_set_status')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-status', '1', 'completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns parsed OK text', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
|
||||
expect(result).toBe('task #1 status=completed');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('invalid transition'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { vi } from 'vitest';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js';
|
||||
|
||||
/**
|
||||
* Creates a mock ITeamctlRunner that records calls and returns predetermined results.
|
||||
*/
|
||||
export function createMockRunner(
|
||||
response: TeamctlResult | ((args: string[]) => TeamctlResult),
|
||||
): ITeamctlRunner & { execute: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
|
||||
return typeof response === 'function' ? response(args) : response;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Success response helpers */
|
||||
export const ok = (stdout: string): TeamctlResult => ({
|
||||
stdout,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
export const fail = (stderr: string): TeamctlResult => ({
|
||||
stdout: '',
|
||||
stderr,
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* Captures registered tools via a mock FastMCP-like server.
|
||||
* Returns a map of tool name → { execute function, parameters schema }.
|
||||
*/
|
||||
export interface CapturedTool {
|
||||
name: string;
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
parameters: unknown;
|
||||
}
|
||||
|
||||
export function createMockServer(): {
|
||||
server: FastMCP;
|
||||
tools: Map<string, CapturedTool>;
|
||||
} {
|
||||
const tools = new Map<string, CapturedTool>();
|
||||
|
||||
const server = {
|
||||
addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => {
|
||||
tools.set(def.name, {
|
||||
name: def.name,
|
||||
execute: def.execute,
|
||||
parameters: def.parameters,
|
||||
});
|
||||
},
|
||||
} as unknown as FastMCP;
|
||||
|
||||
return { server, tools };
|
||||
}
|
||||
Loading…
Reference in a new issue