#!/usr/bin/env node /** * Mosaic Tasks CLI * * Command-line interface for Mosaic Stack's Tasks API * Supports create, list, get, update, and delete operations */ const https = require('https'); const http = require('http'); const { URL } = require('url'); // Configuration from environment const MOSAIC_API_URL = process.env.MOSAIC_API_URL || 'http://localhost:3001'; const MOSAIC_WORKSPACE_ID = process.env.MOSAIC_WORKSPACE_ID; const MOSAIC_API_TOKEN = process.env.MOSAIC_API_TOKEN; // Valid enum values const TASK_STATUSES = ['NOT_STARTED', 'IN_PROGRESS', 'PAUSED', 'COMPLETED', 'ARCHIVED']; const TASK_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH']; /** * Make HTTP request to Mosaic API */ function apiRequest(method, path, body = null) { return new Promise((resolve, reject) => { if (!MOSAIC_API_TOKEN) { reject(new Error('MOSAIC_API_TOKEN environment variable is required')); return; } if (!MOSAIC_WORKSPACE_ID) { reject(new Error('MOSAIC_WORKSPACE_ID environment variable is required')); return; } const url = new URL(path, MOSAIC_API_URL); const isHttps = url.protocol === 'https:'; const client = isHttps ? https : http; const options = { method, hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname + url.search, headers: { 'Authorization': `Bearer ${MOSAIC_API_TOKEN}`, 'X-Workspace-ID': MOSAIC_WORKSPACE_ID, 'Content-Type': 'application/json', }, }; const req = client.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsed = data ? JSON.parse(data) : null; if (res.statusCode >= 200 && res.statusCode < 300) { resolve(parsed); } else { const error = new Error( parsed?.message || `HTTP ${res.statusCode}: ${res.statusMessage}` ); error.statusCode = res.statusCode; error.response = parsed; reject(error); } } catch (err) { reject(new Error(`Failed to parse response: ${err.message}`)); } }); }); req.on('error', (err) => { reject(new Error(`Request failed: ${err.message}`)); }); if (body) { req.write(JSON.stringify(body)); } req.end(); }); } /** * Parse command-line arguments */ function parseArgs(argv) { const args = {}; const positional = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg.startsWith('--')) { const key = arg.slice(2); const value = argv[i + 1]; if (value && !value.startsWith('--')) { args[key] = value; i++; // Skip next arg since we consumed it } else { args[key] = true; // Flag without value } } else { positional.push(arg); } } return { args, positional }; } /** * Build query string from filters */ function buildQueryString(filters) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) { if (value !== undefined && value !== null) { params.append(key, value); } } const qs = params.toString(); return qs ? `?${qs}` : ''; } /** * Create a new task */ async function createTask(args) { const body = { title: args.title, }; if (args.description) body.description = args.description; if (args.status) { if (!TASK_STATUSES.includes(args.status)) { throw new Error(`Invalid status. Must be one of: ${TASK_STATUSES.join(', ')}`); } body.status = args.status; } if (args.priority) { if (!TASK_PRIORITIES.includes(args.priority)) { throw new Error(`Invalid priority. Must be one of: ${TASK_PRIORITIES.join(', ')}`); } body.priority = args.priority; } if (args.due) body.dueDate = args.due; if (args['assignee-id']) body.assigneeId = args['assignee-id']; if (args['project-id']) body.projectId = args['project-id']; if (args['parent-id']) body.parentId = args['parent-id']; if (args['sort-order']) body.sortOrder = parseInt(args['sort-order'], 10); if (!body.title) { throw new Error('--title is required'); } const result = await apiRequest('POST', '/api/tasks', body); console.log(JSON.stringify(result, null, 2)); return result; } /** * List tasks with optional filters */ async function listTasks(args) { const filters = {}; if (args.status) { if (!TASK_STATUSES.includes(args.status)) { throw new Error(`Invalid status. Must be one of: ${TASK_STATUSES.join(', ')}`); } filters.status = args.status; } if (args.priority) { if (!TASK_PRIORITIES.includes(args.priority)) { throw new Error(`Invalid priority. Must be one of: ${TASK_PRIORITIES.join(', ')}`); } filters.priority = args.priority; } if (args['assignee-id']) filters.assigneeId = args['assignee-id']; if (args['project-id']) filters.projectId = args['project-id']; if (args['parent-id']) filters.parentId = args['parent-id']; if (args.page) filters.page = args.page; if (args.limit) filters.limit = args.limit; // Handle overdue filter if (args.overdue) { filters.dueDateTo = new Date().toISOString(); // Exclude completed and archived if (!filters.status) { console.error('Note: Overdue filter shows tasks with due date < now, excluding COMPLETED and ARCHIVED'); } } if (args['due-from']) filters.dueDateFrom = args['due-from']; if (args['due-to']) filters.dueDateTo = args['due-to']; const query = buildQueryString(filters); const result = await apiRequest('GET', `/api/tasks${query}`); // Filter out completed/archived for overdue query if (args.overdue && result.data) { result.data = result.data.filter( task => task.status !== 'COMPLETED' && task.status !== 'ARCHIVED' ); if (result.pagination) { result.pagination.total = result.data.length; } } console.log(JSON.stringify(result, null, 2)); return result; } /** * Get a single task by ID */ async function getTask(taskId) { if (!taskId) { throw new Error('Task ID is required'); } const result = await apiRequest('GET', `/api/tasks/${taskId}`); console.log(JSON.stringify(result, null, 2)); return result; } /** * Update a task */ async function updateTask(taskId, args) { if (!taskId) { throw new Error('Task ID is required'); } const body = {}; if (args.title) body.title = args.title; if (args.description !== undefined) body.description = args.description; if (args.status) { if (!TASK_STATUSES.includes(args.status)) { throw new Error(`Invalid status. Must be one of: ${TASK_STATUSES.join(', ')}`); } body.status = args.status; } if (args.priority) { if (!TASK_PRIORITIES.includes(args.priority)) { throw new Error(`Invalid priority. Must be one of: ${TASK_PRIORITIES.join(', ')}`); } body.priority = args.priority; } if (args.due !== undefined) body.dueDate = args.due || null; if (args['assignee-id'] !== undefined) body.assigneeId = args['assignee-id'] || null; if (args['project-id'] !== undefined) body.projectId = args['project-id'] || null; if (args['parent-id'] !== undefined) body.parentId = args['parent-id'] || null; if (args['sort-order']) body.sortOrder = parseInt(args['sort-order'], 10); if (Object.keys(body).length === 0) { throw new Error('At least one field to update is required'); } const result = await apiRequest('PATCH', `/api/tasks/${taskId}`, body); console.log(JSON.stringify(result, null, 2)); return result; } /** * Delete a task */ async function deleteTask(taskId) { if (!taskId) { throw new Error('Task ID is required'); } const result = await apiRequest('DELETE', `/api/tasks/${taskId}`); console.log(JSON.stringify(result || { success: true }, null, 2)); return result; } /** * Show usage help */ function showHelp() { console.log(` Mosaic Tasks CLI Usage: node tasks.cjs [options] Commands: create Create a new task list List tasks with optional filters get Get a specific task by ID update Update a task delete Delete a task help Show this help message Create Options: --title Task title (required) --description Task description --status Status: ${TASK_STATUSES.join(', ')} --priority Priority: ${TASK_PRIORITIES.join(', ')} --due Due date (ISO 8601 format) --assignee-id Assign to user ID --project-id Link to project ID --parent-id Parent task ID (for subtasks) --sort-order Sort order (integer) List Options: --status Filter by status --priority Filter by priority --assignee-id Filter by assignee --project-id Filter by project --parent-id Filter by parent task --due-from Filter tasks due after this date --due-to Filter tasks due before this date --overdue Show only overdue tasks --page Page number (default: 1) --limit Results per page (default: 50, max: 100) Update Options: --title Update title --description Update description --status Update status --priority Update priority --due Update due date (empty string to clear) --assignee-id Update assignee (empty string to clear) --project-id Update project (empty string to clear) --parent-id Update parent (empty string to clear) --sort-order Update sort order Environment Variables: MOSAIC_API_URL API base URL (default: http://localhost:3001) MOSAIC_WORKSPACE_ID Workspace ID (required) MOSAIC_API_TOKEN Authentication token (required) Examples: # Create a task node tasks.cjs create --title "Review PR #123" --priority HIGH # List high priority tasks node tasks.cjs list --priority HIGH # List overdue tasks node tasks.cjs list --overdue # Get task details node tasks.cjs get abc-123-def-456 # Mark task as completed node tasks.cjs update abc-123-def-456 --status COMPLETED # Delete a task node tasks.cjs delete abc-123-def-456 `); } /** * Main entry point */ async function main() { const { args, positional } = parseArgs(process.argv.slice(2)); const command = positional[0]; try { switch (command) { case 'create': await createTask(args); break; case 'list': await listTasks(args); break; case 'get': await getTask(positional[1]); break; case 'update': await updateTask(positional[1], args); break; case 'delete': await deleteTask(positional[1]); break; case 'help': case undefined: showHelp(); break; default: console.error(`Unknown command: ${command}`); console.error('Run "node tasks.cjs help" for usage information'); process.exit(1); } } catch (error) { console.error('Error:', error.message); if (error.statusCode === 401) { console.error('\nAuthentication failed. Check your MOSAIC_API_TOKEN.'); } else if (error.statusCode === 403) { console.error('\nPermission denied. You may need higher workspace permissions.'); } else if (error.statusCode === 404) { console.error('\nTask not found. Check the task ID.'); } else if (error.response) { console.error('\nAPI Response:', JSON.stringify(error.response, null, 2)); } process.exit(1); } } // Run if executed directly if (require.main === module) { main(); } module.exports = { createTask, listTasks, getTask, updateTask, deleteTask };