import { getToolsBasePath } from '@main/utils/pathDecoder'; import * as fs from 'fs'; import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; const TOOL_VERSION = 4; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node 'use strict'; // Team tools (v${TOOL_VERSION}) const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const TOOL_VERSION = ${TOOL_VERSION}; function nowIso() { return new Date().toISOString(); } function formatError(err) { if (!err) return 'Unknown error'; if (typeof err === 'string') return err; if (err instanceof Error) return err.message || String(err); try { return JSON.stringify(err); } catch { return String(err); } } function die(message, code = 1) { process.stderr.write(String(message).trimEnd() + '\n'); process.exit(code); } function parseArgs(argv) { const out = { _: [], flags: {} }; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (token === '--') { out._.push(...argv.slice(i + 1)); break; } if (token === '-h' || token === '--help') { out.flags.help = true; continue; } if (token.startsWith('--')) { const eq = token.indexOf('='); if (eq !== -1) { const key = token.slice(2, eq); const value = token.slice(eq + 1); out.flags[key] = value; continue; } const key = token.slice(2); const next = argv[i + 1]; if (next != null && !String(next).startsWith('-')) { out.flags[key] = next; i++; } else { out.flags[key] = true; } continue; } if (token.startsWith('-') && token.length > 1) { // minimal short-flag support for (const ch of token.slice(1)) { if (ch === 'h') out.flags.help = true; } continue; } out._.push(token); } return out; } function getHomeDir() { return process.env.HOME || process.env.USERPROFILE || ''; } function getClaudeDir(flags) { const raw = (typeof flags['claude-dir'] === 'string' && flags['claude-dir']) || (typeof flags['claudeDir'] === 'string' && flags['claudeDir']) || (typeof flags['claude_path'] === 'string' && flags['claude_path']) || ''; if (raw) return path.resolve(raw); const inferred = inferClaudeDirFromScriptPath(); if (inferred) return inferred; const home = getHomeDir(); if (!home) die('HOME is not set'); return path.join(home, '.claude'); } function inferClaudeDirFromScriptPath() { // Expected: /tools/teamctl.js const toolsDir = path.dirname(__filename); if (path.basename(toolsDir) !== 'tools') return null; return path.dirname(toolsDir) || null; } function inferTeamNameFromScriptPath() { // From ~/.claude/tools/ the team name cannot be inferred — --team is required return null; } function getTeamName(flags) { const explicit = (typeof flags.team === 'string' && flags.team.trim()) || (typeof flags['teamName'] === 'string' && flags['teamName'].trim()) || ''; if (explicit) return explicit; const inferred = inferTeamNameFromScriptPath(); if (inferred) return inferred; die('Missing --team (and could not infer team name from script path)'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err && err.code === 'ENOENT') return fallback; throw err; } } function atomicWrite(filePath, data) { ensureDir(path.dirname(filePath)); const tmp = String(filePath) + '.tmp.' + String(process.pid) + '.' + String(Math.random().toString(16).slice(2)); fs.writeFileSync(tmp, data, 'utf8'); fs.renameSync(tmp, filePath); } function normalizeStatus(value) { const v = String(value || '').trim(); if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v; return null; } function normalizeColumn(value) { const v = String(value || '').trim(); if (v === 'review' || v === 'approved') return v; return null; } function getPaths(flags, teamName) { const claudeDir = getClaudeDir(flags); const teamDir = path.join(claudeDir, 'teams', teamName); const tasksDir = path.join(claudeDir, 'tasks', teamName); const kanbanPath = path.join(teamDir, 'kanban-state.json'); return { claudeDir, teamDir, tasksDir, kanbanPath }; } function readTask(paths, taskId) { const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); const task = readJson(taskPath, null); if (!task) die('Task not found: ' + String(taskId)); return { taskPath, task }; } function writeTask(taskPath, task) { atomicWrite(taskPath, JSON.stringify(task, null, 2)); const verify = readJson(taskPath, null); if (!verify) die('Task write verification failed'); return verify; } function setTaskStatus(paths, taskId, status) { const normalized = normalizeStatus(status); if (!normalized) die('Invalid status: ' + String(status)); const { taskPath, task } = readTask(paths, taskId); task.status = normalized; writeTask(taskPath, task); } function listTaskIds(tasksDir) { let entries = []; try { entries = fs.readdirSync(tasksDir); } catch (err) { if (err && err.code === 'ENOENT') return []; throw err; } const ids = []; for (const file of entries) { if (!file.endsWith('.json')) continue; if (file.startsWith('.')) continue; const num = Number(file.replace(/\.json$/, '')); if (!Number.isFinite(num)) continue; ids.push(String(num)); } ids.sort((a, b) => Number(a) - Number(b)); return ids; } function getNextTaskId(paths) { const ids = listTaskIds(paths.tasksDir); const maxFromFiles = ids.length ? Number(ids[ids.length - 1]) : 0; const hwmPath = path.join(paths.tasksDir, '.highwatermark'); const hwmRaw = readJson(hwmPath, null); const maxFromHwm = typeof hwmRaw === 'number' ? hwmRaw : Number(String(hwmRaw || '0').trim()); const max = Math.max(maxFromFiles, Number.isFinite(maxFromHwm) ? maxFromHwm : 0); return String(max + 1); } function updateHighwatermark(paths, taskId) { const hwmPath = path.join(paths.tasksDir, '.highwatermark'); atomicWrite(hwmPath, String(taskId)); } function createTask(paths, flags) { const subject = typeof flags.subject === 'string' ? flags.subject.trim() : ''; if (!subject) die('Missing --subject'); const description = typeof flags.description === 'string' ? flags.description : typeof flags.desc === 'string' ? flags.desc : ''; const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined; const explicitStatus = typeof flags.status === 'string' ? flags.status : ''; const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending'); const activeForm = typeof flags.activeForm === 'string' ? flags.activeForm : typeof flags['active-form'] === 'string' ? flags['active-form'] : undefined; ensureDir(paths.tasksDir); const nextId = getNextTaskId(paths); const taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId)); const task = { id: nextId, subject, description: String(description || subject), activeForm: activeForm ? String(activeForm) : undefined, owner, status, blocks: [], blockedBy: [], }; writeTask(taskPath, task); updateHighwatermark(paths, nextId); return task; } function readKanbanState(paths, teamName) { const fallback = { teamName, reviewers: [], tasks: {} }; const parsed = readJson(paths.kanbanPath, fallback); if (!parsed || typeof parsed !== 'object') return fallback; const reviewers = Array.isArray(parsed.reviewers) ? parsed.reviewers.filter((r) => typeof r === 'string' && r.trim()) : []; const tasks = parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {}; return { teamName, reviewers, tasks }; } function writeKanbanState(paths, state) { atomicWrite(paths.kanbanPath, JSON.stringify(state, null, 2)); } function setKanbanColumn(paths, teamName, taskId, column) { const normalized = normalizeColumn(column); if (!normalized) die('Invalid column: ' + String(column)); const state = readKanbanState(paths, teamName); if (normalized === 'review') { state.tasks[String(taskId)] = { column: 'review', reviewStatus: 'pending', reviewer: null, movedAt: nowIso(), }; } else { state.tasks[String(taskId)] = { column: 'approved', movedAt: nowIso(), }; } writeKanbanState(paths, state); } function clearKanban(paths, teamName, taskId) { const state = readKanbanState(paths, teamName); delete state.tasks[String(taskId)]; writeKanbanState(paths, state); } function sendInboxMessage(paths, teamName, flags) { const to = typeof flags.to === 'string' ? flags.to.trim() : ''; if (!to) die('Missing --to'); const text = typeof flags.text === 'string' ? flags.text : ''; if (!text) die('Missing --text'); const summary = typeof flags.summary === 'string' ? flags.summary : undefined; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); ensureDir(path.dirname(inboxPath)); const messageId = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random()); const payload = { from, text, timestamp: nowIso(), read: false, summary, messageId, }; const existing = readJson(inboxPath, []); const list = Array.isArray(existing) ? existing : []; list.push(payload); atomicWrite(inboxPath, JSON.stringify(list, null, 2)); const verify = readJson(inboxPath, []); if (!Array.isArray(verify) || !verify.some((m) => m && m.messageId === messageId)) { die('Inbox write verification failed'); } return { deliveredToInbox: true, messageId }; } function reviewApprove(paths, teamName, taskId, flags) { setKanbanColumn(paths, teamName, taskId, 'approved'); const notify = flags.notify === true || flags['notify-owner'] === true; if (!notify) return; const { task } = readTask(paths, taskId); if (!task.owner) return; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; const note = typeof flags.note === 'string' ? flags.note.trim() : ''; const text = note ? 'Task #' + String(taskId) + ' approved.\n\n' + note : 'Task #' + String(taskId) + ' approved.'; sendInboxMessage(paths, teamName, { to: task.owner, text, summary: 'Approved #' + String(taskId), from, }); } function reviewRequestChanges(paths, teamName, taskId, flags) { const comment = typeof flags.comment === 'string' ? flags.comment.trim() : ''; const { taskPath, task } = readTask(paths, taskId); if (!task.owner) die('No owner found for task ' + String(taskId)); clearKanban(paths, teamName, taskId); task.status = 'in_progress'; writeTask(taskPath, task); const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; const text = 'Task #' + String(taskId) + ' needs fixes.\n\n' + (comment || 'Reviewer requested changes.') + '\n\n' + 'Please fix and mark it as completed when ready.'; sendInboxMessage(paths, teamName, { to: task.owner, text, summary: 'Fix request for #' + String(taskId), from, }); } function printHelp() { const inferred = inferTeamNameFromScriptPath(); const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : ''; process.stdout.write( [ 'teamctl.js v' + String(TOOL_VERSION) + teamHint, '', 'Usage:', ' node teamctl.js task set-status [--team ]', ' node teamctl.js task complete [--team ]', ' node teamctl.js task start [--team ]', ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team ]', ' node teamctl.js kanban set-column [--team ]', ' node teamctl.js kanban clear [--team ]', ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--team ]', ' node teamctl.js review request-changes --comment "..." [--from "member"] [--team ]', ' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team ]', '', 'Options:', ' --team Team name (if not under ~/.claude/teams//tools)', ' --claude-dir Override ~/.claude location', '', ].join('\n') ); } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.flags.help || args._.length === 0) { printHelp(); return; } const domain = args._[0]; const action = args._[1]; const rest = args._.slice(2); const teamName = getTeamName(args.flags); const paths = getPaths(args.flags, teamName); if (domain === 'task') { if (action === 'set-status') { const id = rest[0] || args.flags.id; const status = rest[1] || args.flags.status; if (!id || !status) die('Usage: task set-status '); setTaskStatus(paths, String(id), status); process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n'); return; } if (action === 'complete' || action === 'done') { const id = rest[0] || args.flags.id; if (!id) die('Usage: task complete '); setTaskStatus(paths, String(id), 'completed'); process.stdout.write('OK task #' + String(id) + ' status=completed\n'); return; } if (action === 'start') { const id = rest[0] || args.flags.id; if (!id) die('Usage: task start '); setTaskStatus(paths, String(id), 'in_progress'); process.stdout.write('OK task #' + String(id) + ' status=in_progress\n'); return; } if (action === 'create') { const task = createTask(paths, args.flags); const notify = args.flags.notify === true || args.flags['notify-owner'] === true; if (notify && task.owner) { const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user'; const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".']; const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim() : typeof args.flags.desc === 'string' ? args.flags.desc.trim() : ''; if (rawDesc && rawDesc !== task.subject) { parts.push('\nDescription:\n' + rawDesc); } const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : ''; if (prompt) { parts.push('\nInstructions:\n' + prompt); } parts.push( '\n${'```'}info_for_agent', 'Update task status using:', 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id), 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id), '${'```'}' ); sendInboxMessage(paths, teamName, { to: task.owner, text: parts.join('\n'), summary: 'New task #' + String(task.id) + ' assigned', from, }); } process.stdout.write(JSON.stringify(task, null, 2) + '\n'); return; } if (action === 'get') { const id = rest[0] || args.flags.id; if (!id) die('Usage: task get '); const { task } = readTask(paths, String(id)); process.stdout.write(JSON.stringify(task, null, 2) + '\n'); return; } if (action === 'list') { const ids = listTaskIds(paths.tasksDir); const tasks = []; for (const id of ids) { try { tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null)); } catch {} } process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n'); return; } die('Unknown task action: ' + String(action)); } if (domain === 'kanban') { if (action === 'set-column') { const id = rest[0] || args.flags.id; const column = rest[1] || args.flags.column; if (!id || !column) die('Usage: kanban set-column '); setKanbanColumn(paths, teamName, String(id), column); process.stdout.write( 'OK kanban #' + String(id) + ' column=' + String(column) + '\n' ); return; } if (action === 'clear' || action === 'remove') { const id = rest[0] || args.flags.id; if (!id) die('Usage: kanban clear '); clearKanban(paths, teamName, String(id)); process.stdout.write('OK kanban #' + String(id) + ' cleared\n'); return; } if (action === 'reviewers') { const sub = rest[0]; const name = rest[1]; const state = readKanbanState(paths, teamName); if (sub === 'list') { process.stdout.write(JSON.stringify(state.reviewers, null, 2) + '\n'); return; } if ((sub === 'add' || sub === 'remove') && (!name || !String(name).trim())) { die('Usage: kanban reviewers add|remove '); } const trimmed = String(name || '').trim(); const before = new Set(state.reviewers); if (sub === 'add') before.add(trimmed); else if (sub === 'remove') before.delete(trimmed); else die('Usage: kanban reviewers list|add|remove ...'); state.reviewers = [...before]; writeKanbanState(paths, state); process.stdout.write('OK reviewers ' + String(sub) + '\n'); return; } die('Unknown kanban action: ' + String(action)); } if (domain === 'review') { if (action === 'approve') { const id = rest[0] || args.flags.id; if (!id) die('Usage: review approve '); reviewApprove(paths, teamName, String(id), args.flags); process.stdout.write('OK review #' + String(id) + ' approved\n'); return; } if (action === 'request-changes') { const id = rest[0] || args.flags.id; if (!id) die('Usage: review request-changes --comment "..."'); reviewRequestChanges(paths, teamName, String(id), args.flags); process.stdout.write('OK review #' + String(id) + ' requested changes\n'); return; } die('Unknown review action: ' + String(action)); } if (domain === 'message') { if (action === 'send') { const result = sendInboxMessage(paths, teamName, args.flags); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; } die('Unknown message action: ' + String(action)); } die('Unknown domain: ' + String(domain)); } main().catch((err) => { die(formatError(err)); }); `; return script.trimStart(); } export class TeamAgentToolsInstaller { async ensureInstalled(): Promise { const toolsDir = getToolsBasePath(); const toolPath = path.join(toolsDir, TOOL_FILE_NAME); await fs.promises.mkdir(toolsDir, { recursive: true }); const desired = buildTeamCtlScript(); let current: string | null = null; try { current = await fs.promises.readFile(toolPath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } if (current?.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) { return toolPath; } await atomicWriteAsync(toolPath, desired); return toolPath; } }