feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for: - projects-meta-mcp v2.25.0 (TypeScript/Node) - wiki-graph v0.3.1 (TypeScript/Node) - interns-mcp v0.3.3 (Python/FastMCP) .gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc) bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runTasksCli } from '../../src/tasks-cli.js';
|
||||
import type { ToolDef, ToolResult } from '../../src/tools/types.js';
|
||||
|
||||
function fakeTool(
|
||||
name: string,
|
||||
impl: (args: Record<string, unknown>) => ToolResult,
|
||||
): ToolDef {
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async (args) => impl(args),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResult(value: unknown): ToolResult {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||
}
|
||||
|
||||
describe('runTasksCli', () => {
|
||||
it('routes claim-next to tasks.claim_next with parsed flags and prints the handler JSON', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, claimed: true, slug: 'foo' });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'claim-next',
|
||||
'--claimer-identity',
|
||||
'host:claude-opus:123',
|
||||
'--runtime',
|
||||
'claude-opus',
|
||||
'--requirements',
|
||||
'needs-db,needs-internet',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
claimer_identity: 'host:claude-opus:123',
|
||||
confirm: true,
|
||||
filter: { runtime: 'claude-opus', requirements: ['needs-db', 'needs-internet'] },
|
||||
});
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, claimed: true, slug: 'foo' });
|
||||
});
|
||||
|
||||
it('routes append-decision-trail to tasks.append_decision_trail, splitting the chain', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true, trail_ref: 'x' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'cache key includes locale?',
|
||||
'--decided-by',
|
||||
'arbiter',
|
||||
'--blast-radius',
|
||||
'reversible',
|
||||
'--ruling',
|
||||
'include locale',
|
||||
'--rationale',
|
||||
'avoids bleed',
|
||||
'--escalation-chain',
|
||||
'brief,read-repo',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
blast_radius: 'reversible',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids bleed',
|
||||
escalation_chain: ['brief', 'read-repo'],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits ruling on a halt trail entry', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'priorities?',
|
||||
'--decided-by',
|
||||
'human-required',
|
||||
'--escalation-chain',
|
||||
'brief,arbiter-fork',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(received).not.toHaveProperty('ruling');
|
||||
expect(received!.decided_by).toBe('human-required');
|
||||
});
|
||||
|
||||
it('routes park-question to tasks.park_question with question + optional claim_token', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.park_question', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ parked: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'park-question',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'drop the legacy column?',
|
||||
'--claim-token',
|
||||
'tok-123',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'drop the legacy column?',
|
||||
claim_token: 'tok-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes get-status to tasks.get_status', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.get_status', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ found: true, status: 'blocked' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['get-status', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task' });
|
||||
});
|
||||
|
||||
it('routes heartbeat to tasks.heartbeat with slug + claim_token (+ optional target_project)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.heartbeat', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['heartbeat', '--slug', 'foo', '--claim-token', 'tok-1', '--target-project', 'victor/books'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ slug: 'foo', claim_token: 'tok-1', target_project: 'victor/books' });
|
||||
});
|
||||
|
||||
it('routes close to tasks.close with target_project + slug + note + confirm', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.close', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['close', '--target-project', 'victor/books', '--slug', 'foo', '--note', 'done via poller', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
note: 'done via poller',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes update to tasks.update with target_project + slug + status + where_stopped', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'update',
|
||||
'--target-project',
|
||||
'victor/books',
|
||||
'--slug',
|
||||
'foo',
|
||||
'--status',
|
||||
'blocked',
|
||||
'--where-stopped',
|
||||
'spawn exited 1: boom',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: 'spawn exited 1: boom',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an explicit empty --blocker= through as blocker:"" (clear the blocker)', async () => {
|
||||
// The ops watchdog resets a transient-blocked task with `--status=ready
|
||||
// --blocker=`; the empty value must reach the handler so updateTaskFields
|
||||
// strips the stale **Blocker:** line, not be dropped as a falsy flag.
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--blocker=', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'ready',
|
||||
blocker: '',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits blocker entirely when --blocker is absent', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).not.toHaveProperty('blocker');
|
||||
});
|
||||
|
||||
it('returns exit code 1 when the handler result isError', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => ({
|
||||
content: [{ type: 'text', text: 'cache missing — run sync first' }],
|
||||
isError: true,
|
||||
})),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next', '--claimer-identity', 'h:r:1'], {
|
||||
tools,
|
||||
log: (s) => out.push(s),
|
||||
});
|
||||
expect(code).toBe(1);
|
||||
expect(out.join('\n')).toContain('cache missing');
|
||||
});
|
||||
|
||||
it('returns exit code 2 and usage on an unknown command', async () => {
|
||||
const err: string[] = [];
|
||||
const code = await runTasksCli(['frobnicate'], { tools: [], errLog: (s) => err.push(s) });
|
||||
expect(code).toBe(2);
|
||||
expect(err.join('\n')).toContain('unknown command: frobnicate');
|
||||
});
|
||||
|
||||
it('a throwing handler (e.g. zod parse on a missing required flag) → JSON + non-zero, not an uncaught throw', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => {
|
||||
throw new Error('claimer_identity: Required');
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next'], { tools, log: (s) => out.push(s) });
|
||||
expect(code).not.toBe(0);
|
||||
const parsed = JSON.parse(out.join('\n'));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toContain('claimer_identity');
|
||||
});
|
||||
|
||||
it('--flag=value form: a value beginning with -- survives (blocked-path where_stopped)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=blocked', '--where-stopped=--boom: bad flag', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: '--boom: bad flag',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes list-blocked to tasks.list_blocked with optional project_allowlist', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(
|
||||
['list-blocked', '--projects', 'OpeItcLoc03/alpha,OpeItcLoc03/beta'],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ project_allowlist: ['OpeItcLoc03/alpha', 'OpeItcLoc03/beta'] });
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, tasks: [] });
|
||||
});
|
||||
|
||||
it('routes list-blocked without --projects (no allowlist)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(['list-blocked'], { tools, log: () => {} });
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user