From b1ee0da1b8514b754d1212d731167322021348ba Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 25 Feb 2026 14:59:15 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- app/api/activity/route.ts | 29 ++++ hooks/use-activity-feed.ts | 334 ++++++++++++++++++------------------- lib/data/gantt-api.ts | 14 +- scripts/README.md | 240 ++++++++++++++++++++++++++ scripts/lib/api_client.sh | 111 ++++++++++++ scripts/mc.sh | 265 +++++++++++++++++++++++++++++ scripts/project.sh | 36 +++- scripts/sprint.sh | 39 +++++ scripts/task.sh | 36 +++- 9 files changed, 923 insertions(+), 181 deletions(-) create mode 100644 app/api/activity/route.ts create mode 100644 scripts/README.md create mode 100755 scripts/lib/api_client.sh create mode 100755 scripts/mc.sh create mode 100755 scripts/sprint.sh diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts new file mode 100644 index 0000000..aaad40e --- /dev/null +++ b/app/api/activity/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { fetchGanttApi } from "@/lib/data/gantt-api"; + +interface ActivityPayload { + tasks?: unknown[]; + projects?: unknown[]; +} + +export async function GET() { + try { + const payload = await fetchGanttApi("/tasks?scope=all"); + + return NextResponse.json( + { + tasks: Array.isArray(payload.tasks) ? payload.tasks : [], + projects: Array.isArray(payload.projects) ? payload.projects : [], + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch activity data"; + console.error("[api/activity] Request failed", error); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/hooks/use-activity-feed.ts b/hooks/use-activity-feed.ts index e72ec6d..3e55514 100644 --- a/hooks/use-activity-feed.ts +++ b/hooks/use-activity-feed.ts @@ -1,13 +1,18 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { supabaseClient } from "@/lib/supabase/client"; import type { Database, ActivityItem } from "@/lib/supabase/database.types"; -type Task = Database['public']['Tables']['tasks']['Row']; -type Project = Database['public']['Tables']['projects']['Row']; +type Project = Pick; +type TaskStatus = Database["public"]["Tables"]["tasks"]["Row"]["status"]; -export type ActivityFilterType = 'all' | 'task_created' | 'task_completed' | 'task_updated' | 'comment_added' | 'task_assigned'; +export type ActivityFilterType = + | "all" + | "task_created" + | "task_completed" + | "task_updated" + | "comment_added" + | "task_assigned"; interface UseActivityFeedOptions { limit?: number; @@ -15,10 +20,8 @@ interface UseActivityFeedOptions { filterType?: ActivityFilterType; } -let canUseLeanTasksSelect = true; - interface UserDirectoryEntry { - name: string; + name?: string; avatarUrl?: string; } @@ -32,10 +35,35 @@ interface NormalizedTaskComment { replies: NormalizedTaskComment[]; } -interface UserDirectoryRow { +interface ActivityApiTask { id: string; - name: string | null; - avatar_url: string | null; + title: string; + status: TaskStatus; + projectId: string; + createdAt: string; + updatedAt: string; + createdById?: string; + createdByName?: string; + createdByAvatarUrl?: string; + updatedById?: string; + updatedByName?: string; + updatedByAvatarUrl?: string; + assigneeId?: string; + assigneeName?: string; + assigneeAvatarUrl?: string; + comments?: unknown; +} + +interface ActivityApiProject { + id: string; + name: string; + color: string; +} + +interface ActivityApiResponse { + tasks?: ActivityApiTask[]; + projects?: ActivityApiProject[]; + error?: string; } function normalizeUserId(value: string | null | undefined): string | null { @@ -50,9 +78,9 @@ function resolveUserName( fallbackName?: string | null ): string { const normalizedUserId = normalizeUserId(userId); - if (normalizedUserId && users[normalizedUserId]?.name) return users[normalizedUserId].name; + if (normalizedUserId && users[normalizedUserId]?.name) return users[normalizedUserId].name as string; if (fallbackName && fallbackName.trim().length > 0) return fallbackName; - if (!userId) return 'Unknown'; + if (!userId) return "Unknown"; const normalizedId = userId.trim().toLowerCase(); if (normalizedId === "assistant") return "Assistant"; @@ -126,146 +154,117 @@ function flattenTaskComments( return flattened; } -function buildUserDirectory(rows: Array<{ id: string; name: string | null; avatar_url: string | null }>): UserDirectory { +function buildUserDirectoryFromTasks(tasks: ActivityApiTask[]): UserDirectory { const directory: UserDirectory = {}; - rows.forEach((row) => { - const rawId = toNonEmptyString(row.id); - const id = normalizeUserId(rawId); - const name = toNonEmptyString(row.name); - if (!id || !name) return; + const upsert = (userId?: string, name?: string, avatarUrl?: string) => { + const normalizedUserId = normalizeUserId(userId); + if (!normalizedUserId) return; - directory[id] = { - name, - avatarUrl: toNonEmptyString(row.avatar_url) ?? undefined, + const existing = directory[normalizedUserId] ?? {}; + const nextName = toNonEmptyString(name) ?? existing.name; + const nextAvatarUrl = toNonEmptyString(avatarUrl) ?? existing.avatarUrl; + + if (!nextName && !nextAvatarUrl) return; + + directory[normalizedUserId] = { + ...(nextName ? { name: nextName } : {}), + ...(nextAvatarUrl ? { avatarUrl: nextAvatarUrl } : {}), }; + }; + + tasks.forEach((task) => { + upsert(task.createdById, task.createdByName, task.createdByAvatarUrl); + upsert(task.updatedById, task.updatedByName, task.updatedByAvatarUrl); + upsert(task.assigneeId, task.assigneeName, task.assigneeAvatarUrl); }); return directory; } export function useActivityFeed(options: UseActivityFeedOptions = {}) { - const { limit = 50, projectId, filterType = 'all' } = options; + const { limit = 50, projectId, filterType = "all" } = options; const isDebug = process.env.NODE_ENV !== "production"; - + const [activities, setActivities] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchProjects = useCallback(async () => { - try { - const { data, error } = await supabaseClient - .from('projects') - .select('id, name, color') - .order('name'); - - if (error) throw error; - setProjects(data || []); - } catch (err) { - console.error('Error fetching projects:', err); - } - }, []); - const fetchActivities = useCallback(async () => { setLoading(true); setError(null); - + try { - let userRows: UserDirectoryRow[] = []; - try { - const response = await fetch("/api/users/directory", { cache: "no-store" }); - if (response.ok) { - const payload = (await response.json()) as { users?: UserDirectoryRow[] }; - userRows = Array.isArray(payload.users) ? payload.users : []; - } else { - if (isDebug) { - console.warn("[activity-feed] users directory API returned non-OK status", { status: response.status }); - } - } - } catch (error) { - if (isDebug) { - console.warn("[activity-feed] users directory API request failed, falling back to client query", error); - } + const response = await fetch("/api/activity", { cache: "no-store" }); + const payload = (await response.json().catch(() => null)) as ActivityApiResponse | null; + + if (!response.ok) { + const message = + payload?.error || + `Activity API request failed with status ${response.status}`; + throw new Error(message); } - if (userRows.length === 0) { - const { data: fallbackUserRows, error: usersError } = await supabaseClient - .from("users") - .select("id, name, avatar_url"); - if (usersError) throw usersError; - userRows = (fallbackUserRows || []) as UserDirectoryRow[]; - } + const apiTasks = Array.isArray(payload?.tasks) ? payload.tasks : []; + const apiProjects = (Array.isArray(payload?.projects) ? payload.projects : []).filter( + (project) => + typeof project?.id === "string" && + typeof project?.name === "string" && + typeof project?.color === "string" + ); - const userDirectory = buildUserDirectory(userRows); + const visibleProjects = apiProjects.map((project) => ({ + id: project.id, + name: project.name, + color: project.color, + })); + setProjects(visibleProjects); + + const userDirectory = buildUserDirectoryFromTasks(apiTasks); if (isDebug) { - console.log("[activity-feed] user directory loaded", { + console.log("[activity-feed] user directory derived from tasks", { userCount: Object.keys(userDirectory).length, sampleUserIds: Object.keys(userDirectory).slice(0, 5), }); } - const runTasksQuery = async (selectClause: string) => { - let query = supabaseClient - .from('tasks') - .select(selectClause) - .order('updated_at', { ascending: false }) - .limit(limit); + const projectById = new Map(visibleProjects.map((project) => [project.id, project])); - if (projectId) { - query = query.eq('project_id', projectId); - } + const tasks = apiTasks + .filter((task) => (projectId ? task.projectId === projectId : true)) + .map((task) => ({ + id: task.id, + title: task.title, + status: task.status, + project_id: task.projectId, + created_at: task.createdAt, + updated_at: task.updatedAt, + created_by_id: task.createdById ?? null, + created_by_name: task.createdByName ?? null, + updated_by_id: task.updatedById ?? null, + updated_by_name: task.updatedByName ?? null, + assignee_id: task.assigneeId ?? null, + assignee_name: task.assigneeName ?? null, + comments: task.comments, + projects: projectById.get(task.projectId) + ? { + name: projectById.get(task.projectId)?.name, + color: projectById.get(task.projectId)?.color, + } + : null, + assignee: task.assigneeId + ? { + id: task.assigneeId, + name: task.assigneeName ?? null, + } + : null, + })); - return query; - }; - - let tasks: unknown[] | null = null; - let tasksError: unknown = null; - - if (canUseLeanTasksSelect) { - const leanResult = await runTasksQuery(` - id, - title, - status, - project_id, - created_at, - updated_at, - created_by_id, - created_by_name, - updated_by_id, - updated_by_name, - assignee_id, - assignee_name, - comments, - projects:project_id (name, color) - `); - tasks = leanResult.data; - tasksError = leanResult.error; - } - - if (tasksError || !canUseLeanTasksSelect) { - if (tasksError) canUseLeanTasksSelect = false; - const fallback = await runTasksQuery(` - *, - projects:project_id (*), - assignee:assignee_id (id, name) - `); - tasks = fallback.data; - tasksError = fallback.error; - } - - if (tasksError) throw tasksError; - - // Convert tasks to activity items const activityItems: ActivityItem[] = []; - - const typedTasks = (tasks || []) as Array | null; - assignee?: { id?: string; name?: string | null } | null; - }>; const globalTaskUserNameById: Record = {}; - typedTasks.forEach((task) => { + tasks.forEach((task) => { const createdById = normalizeUserId(task.created_by_id); const updatedById = normalizeUserId(task.updated_by_id); const assigneeId = normalizeUserId(task.assignee_id); @@ -277,7 +276,7 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { } }); - typedTasks.forEach((task) => { + tasks.forEach((task) => { const project = task.projects; const taskUserNameById: Record = {}; @@ -294,7 +293,7 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { if (assigneeId && (task.assignee_name || task.assignee?.name)) { taskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || ""; } - + const createdByName = resolveUserName( task.created_by_id, userDirectory, @@ -312,6 +311,7 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { ); const createdByAvatarUrl = resolveUserAvatarUrl(task.created_by_id, userDirectory); const updatedByAvatarUrl = resolveUserAvatarUrl(task.updated_by_id, userDirectory); + if (isDebug && (!task.created_by_id || createdByName === "User")) { console.log("[activity-feed] created_by fallback", { taskId: task.id, @@ -320,81 +320,78 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { resolvedName: createdByName, }); } - - // Task creation activity - if (filterType === 'all' || filterType === 'task_created') { + + if (filterType === "all" || filterType === "task_created") { activityItems.push({ id: `${task.id}-created`, - type: 'task_created', + type: "task_created", task_id: task.id, task_title: task.title, project_id: task.project_id, - project_name: project?.name || 'Unknown Project', - project_color: project?.color || '#6B7280', - user_id: task.created_by_id || '', + project_name: project?.name || "Unknown Project", + project_color: project?.color || "#6B7280", + user_id: task.created_by_id || "", user_name: createdByName, user_avatar_url: createdByAvatarUrl, timestamp: pickTimestamp(task.created_at, task.updated_at), }); } - - // Task completion activity - if (task.status === 'done' && (filterType === 'all' || filterType === 'task_completed')) { + + if (task.status === "done" && (filterType === "all" || filterType === "task_completed")) { activityItems.push({ id: `${task.id}-completed`, - type: 'task_completed', + type: "task_completed", task_id: task.id, task_title: task.title, project_id: task.project_id, - project_name: project?.name || 'Unknown Project', - project_color: project?.color || '#6B7280', - user_id: task.updated_by_id || task.created_by_id || '', + project_name: project?.name || "Unknown Project", + project_color: project?.color || "#6B7280", + user_id: task.updated_by_id || task.created_by_id || "", user_name: updatedByName || createdByName, user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl, timestamp: pickTimestamp(task.updated_at, task.created_at), }); } - - // Assignment activity - if (task.assignee_id && (filterType === 'all' || filterType === 'task_assigned')) { + + if (task.assignee_id && (filterType === "all" || filterType === "task_assigned")) { activityItems.push({ id: `${task.id}-assigned`, - type: 'task_assigned', + type: "task_assigned", task_id: task.id, task_title: task.title, project_id: task.project_id, - project_name: project?.name || 'Unknown Project', - project_color: project?.color || '#6B7280', - user_id: task.updated_by_id || task.created_by_id || '', + project_name: project?.name || "Unknown Project", + project_color: project?.color || "#6B7280", + user_id: task.updated_by_id || task.created_by_id || "", user_name: updatedByName || createdByName, user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl, timestamp: pickTimestamp(task.updated_at, task.created_at), details: `Assigned to ${assigneeName}`, }); } - - // Task update activity (if updated after creation and not completed) - if (task.updated_at !== task.created_at && - task.status !== 'done' && - (filterType === 'all' || filterType === 'task_updated')) { + + if ( + task.updated_at !== task.created_at && + task.status !== "done" && + (filterType === "all" || filterType === "task_updated") + ) { activityItems.push({ id: `${task.id}-updated`, - type: 'task_updated', + type: "task_updated", task_id: task.id, task_title: task.title, project_id: task.project_id, - project_name: project?.name || 'Unknown Project', - project_color: project?.color || '#6B7280', - user_id: task.updated_by_id || '', + project_name: project?.name || "Unknown Project", + project_color: project?.color || "#6B7280", + user_id: task.updated_by_id || "", user_name: updatedByName, user_avatar_url: updatedByAvatarUrl, timestamp: pickTimestamp(task.updated_at, task.created_at), details: `Status: ${task.status}`, }); } - - // Comment activities - if (task.comments && Array.isArray(task.comments) && (filterType === 'all' || filterType === 'comment_added')) { + + if (task.comments && Array.isArray(task.comments) && (filterType === "all" || filterType === "comment_added")) { const comments = normalizeTaskComments(task.comments); const flattenedComments = flattenTaskComments(comments); @@ -409,6 +406,7 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { : null) ?? null ); const commentAuthorAvatarUrl = resolveUserAvatarUrl(commentAuthorId, userDirectory); + if (isDebug && (commentAuthorName === "User" || commentAuthorName === "Unknown")) { console.log("[activity-feed] comment author fallback", { taskId: task.id, @@ -422,12 +420,12 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { activityItems.push({ id: `${task.id}-comment-${comment.id}-${path}`, - type: 'comment_added', + type: "comment_added", task_id: task.id, task_title: task.title, project_id: task.project_id, - project_name: project?.name || 'Unknown Project', - project_color: project?.color || '#6B7280', + project_name: project?.name || "Unknown Project", + project_color: project?.color || "#6B7280", user_id: commentAuthorId, user_name: commentAuthorName, user_avatar_url: commentAuthorAvatarUrl, @@ -437,13 +435,9 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { }); } }); - - // Sort by timestamp descending - activityItems.sort((a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ); - - // Apply limit after all activities are collected + + activityItems.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + setActivities(activityItems.slice(0, limit)); if (isDebug) { console.log("[activity-feed] activities prepared", { @@ -454,31 +448,25 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { }); } } catch (err) { - console.error('Error fetching activities:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch activities'); + console.error("Error fetching activities:", err); + setError(err instanceof Error ? err.message : "Failed to fetch activities"); } finally { setLoading(false); } }, [limit, projectId, filterType]); useEffect(() => { - fetchProjects(); fetchActivities(); - }, [fetchProjects, fetchActivities]); + }, [fetchActivities]); - // Poll for updates every 30 seconds (since realtime WebSocket is disabled) useEffect(() => { const interval = setInterval(() => { fetchActivities(); - }, 30000); // 30 seconds + }, 30000); return () => clearInterval(interval); }, [fetchActivities]); - // Note: Real-time subscription disabled due to WebSocket connection issues - // The activity feed uses regular HTTP polling instead (30s interval) - // To re-enable realtime, configure Supabase Realtime in your project settings - const refresh = useCallback(() => { fetchActivities(); }, [fetchActivities]); diff --git a/lib/data/gantt-api.ts b/lib/data/gantt-api.ts index 42264a5..92d72c3 100644 --- a/lib/data/gantt-api.ts +++ b/lib/data/gantt-api.ts @@ -64,15 +64,25 @@ export async function fetchGanttApi(endpoint: string): Promise { }, }); - const payload = (await response.json().catch(() => null)) as + const contentType = response.headers.get("content-type") || ""; + const isJson = contentType.includes("application/json"); + const payload = (isJson ? await response.json().catch(() => null) : null) as | { error?: string; message?: string } | null; + const nonJsonBody = !isJson ? await response.text().catch(() => "") : ""; if (!response.ok) { - const details = payload?.error || payload?.message || response.statusText; + const details = + payload?.error || + payload?.message || + (nonJsonBody ? nonJsonBody.replace(/\s+/g, " ").slice(0, 200) : response.statusText); throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): ${details}`); } + if (!isJson) { + throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): expected JSON response`); + } + return payload as T; })(); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..ba35d4e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,240 @@ +# Mission Control CLI + +A Next.js API-based CLI for managing Mission Control. Follows the same architecture principles as the Gantt Board CLI. + +## Architecture + +The Mission Control CLI follows a **clean API passthrough architecture**: + +1. **API is the source of truth** - All business logic lives in the Mission Control API endpoints +2. **CLI is thin** - CLI scripts parse arguments and call API endpoints, no direct database access +3. **Shared code via delegation** - Task/project/sprint operations delegate to Gantt Board CLI +4. **Mission Control specific features** call Mission Control API directly + +## Quick Start + +```bash +# Authenticate +./scripts/mc.sh auth login user@example.com password + +# Search across tasks, projects, documents +./scripts/mc.sh search "api design" + +# List tasks with due dates +./scripts/mc.sh due-dates + +# Task operations (delegates to gantt-board) +./scripts/mc.sh task list --status open +./scripts/mc.sh task create --title "New task" --project "Mission Control" + +# Project operations (delegates to gantt-board) +./scripts/mc.sh project list + +# Sprint operations (delegates to gantt-board) +./scripts/mc.sh sprint list --active +``` + +## Scripts + +### Main CLI + +- **`mc.sh`** - Main entry point for all Mission Control CLI operations + +### Wrapper Scripts (Delegate to Gantt Board) + +- **`task.sh`** - Task operations (delegates to gantt-board/scripts/task.sh) +- **`project.sh`** - Project operations (delegates to gantt-board/scripts/project.sh) +- **`sprint.sh`** - Sprint operations (delegates to gantt-board/scripts/sprint.sh) + +### Library + +- **`lib/api_client.sh`** - Shared HTTP client for Mission Control API calls + +### Utilities + +- **`update-task-status.js`** - Update task status (delegates to gantt-board) + +## Configuration + +Environment variables: + +```bash +# Mission Control API URL +export MC_API_URL="http://localhost:3001/api" + +# Path to gantt-board (auto-detected if not set) +export GANTT_BOARD_DIR="/path/to/gantt-board" + +# Cookie file for authentication +export MC_COOKIE_FILE="$HOME/.config/mission-control/cookies.txt" +``` + +## Command Reference + +### Authentication + +```bash +./mc.sh auth login +./mc.sh auth logout +./mc.sh auth session +``` + +### Search + +```bash +./mc.sh search "query string" +``` + +Searches across: +- Tasks (title, description) +- Projects (name, description) +- Sprints (name, goal) +- Documents (title, content) + +### Tasks with Due Dates + +```bash +./mc.sh due-dates +``` + +Returns tasks with due dates, ordered by due date. + +### Documents + +```bash +./mc.sh documents list +./mc.sh documents get +``` + +### Task Operations (via Gantt Board) + +```bash +./mc.sh task list [--status ] [--priority ] +./mc.sh task get +./mc.sh task create --title "..." [--description "..."] [--project ] +./mc.sh task update [--status ] [--priority ] +./mc.sh task delete +``` + +See Gantt Board CLI documentation for full task command reference. + +### Project Operations (via Gantt Board) + +```bash +./mc.sh project list +./mc.sh project get +./mc.sh project create --name "..." [--description "..."] +./mc.sh project update [--name "..."] [--description "..."] +``` + +### Sprint Operations (via Gantt Board) + +```bash +./mc.sh sprint list [--active] +./mc.sh sprint get +./mc.sh sprint create --name "..." [--goal "..."] +./mc.sh sprint close +``` + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Mission Control CLI │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ mc.sh │ │ task.sh │ │ project.sh │ │ +│ │ (main) │ │ (wrapper) │ │ (wrapper) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ +│ │ │ │ │ +│ │ └──────────┬──────────┘ │ +│ │ │ │ +│ │ ┌──────────────▼──────────────┐ │ +│ │ │ Gantt Board CLI │ │ +│ │ │ (task.sh, project.sh) │ │ +│ │ └──────────────┬──────────────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌─────────────────────┐ │ +│ │ │ Gantt Board API │ │ +│ │ └─────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Mission Control API │ │ +│ │ /api/search, /api/tasks/with-due-dates, etc │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Testing + +Run the CLI contract test: + +```bash +npm run test:cli-contract +``` + +This verifies: +1. Mission Control CLI wrappers delegate to gantt-board CLI +2. No direct database references in scripts/ +3. update-task-status.js delegates to gantt-board task.sh + +## Design Principles + +1. **No Direct Database Access** - CLI scripts never call the database directly +2. **API Passthrough** - All operations go through API endpoints +3. **Shared Functionality** - Common operations (tasks, projects, sprints) use Gantt Board +4. **Clean Separation** - Mission Control specific features use Mission Control API + +## Adding New Commands + +To add a new Mission Control specific command: + +1. Create the API endpoint in `app/api//route.ts` +2. Add the command handler in `scripts/mc.sh` +3. Use `lib/api_client.sh` functions for HTTP calls +4. Document the command in this README + +Example: + +```bash +# In mc.sh +handle_feature() { + mc_get "/feature" | jq . +} +``` + +## Troubleshooting + +### "GANTT_BOARD_DIR not set" + +Set the environment variable: + +```bash +export GANTT_BOARD_DIR=/path/to/gantt-board +``` + +Or use the auto-detection by placing gantt-board in a standard location: +- `../../../gantt-board` (relative to mission-control) +- `$HOME/Documents/Projects/OpenClaw/Web/gantt-board` + +### "Not authenticated" + +Login first: + +```bash +./scripts/mc.sh auth login user@example.com password +``` + +### API Connection Errors + +Verify Mission Control is running: + +```bash +curl http://localhost:3001/api/auth/session +``` + +## Related Documentation + +- [Gantt Board CLI](../../gantt-board/scripts/README.md) +- [Mission Control API](../app/api/) diff --git a/scripts/lib/api_client.sh b/scripts/lib/api_client.sh new file mode 100755 index 0000000..887e803 --- /dev/null +++ b/scripts/lib/api_client.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Mission Control API Client Library +# Shared HTTP client for Mission Control CLI scripts +# Follows the same pattern as gantt-board/scripts/lib/api_client.sh + +set -euo pipefail + +# Configuration +MC_API_URL="${MC_API_URL:-http://localhost:3001/api}" +MC_COOKIE_FILE="${MC_COOKIE_FILE:-$HOME/.config/mission-control/cookies.txt}" + +# Ensure cookie directory exists +mkdir -p "$(dirname "$MC_COOKIE_FILE")" + +# Make authenticated API call to Mission Control +# Usage: mc_api_call [data] +mc_api_call() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + local url="${MC_API_URL}${endpoint}" + local curl_opts=( + -s + -b "$MC_COOKIE_FILE" + -c "$MC_COOKIE_FILE" + -H "Content-Type: application/json" + ) + + if [[ -n "$data" ]]; then + curl_opts+=(-d "$data") + fi + + curl "${curl_opts[@]}" -X "$method" "$url" +} + +# GET request helper +# Usage: mc_get +mc_get() { + mc_api_call "GET" "$1" +} + +# POST request helper +# Usage: mc_post [data] +mc_post() { + local endpoint="$1" + local data="${2:-}" + mc_api_call "POST" "$endpoint" "$data" +} + +# DELETE request helper +# Usage: mc_delete +mc_delete() { + mc_api_call "DELETE" "$1" +} + +# URL encode a string +# Usage: url_encode +url_encode() { + local str="$1" + printf '%s' "$str" | jq -sRr @uri +} + +# Check if user is authenticated (cookie exists and is valid) +mc_is_authenticated() { + if [[ ! -f "$MC_COOKIE_FILE" ]]; then + return 1 + fi + + # Try to get session - if it fails, not authenticated + local response + response=$(mc_get "/auth/session" 2>/dev/null || echo '{"user":null}') + + # Check if we got a valid user back + echo "$response" | jq -e '.user != null' >/dev/null 2>&1 +} + +# Login to Mission Control +# Usage: mc_login +mc_login() { + local email="$1" + local password="$2" + + local response + response=$(mc_post "/auth/login" "$(jq -n --arg email "$email" --arg password "$password" '{email:$email,password:$password}')") + + if echo "$response" | jq -e '.error' >/dev/null 2>&1; then + echo "Login failed: $(echo "$response" | jq -r '.error')" >&2 + return 1 + fi + + echo "Login successful" + return 0 +} + +# Logout from Mission Control +mc_logout() { + mc_post "/auth/logout" + rm -f "$MC_COOKIE_FILE" + echo "Logged out" +} + +# Export functions for use in other scripts +export -f mc_api_call +export -f mc_get +export -f mc_post +export -f mc_delete +export -f url_encode +export -f mc_is_authenticated +export -f mc_login +export -f mc_logout diff --git a/scripts/mc.sh b/scripts/mc.sh new file mode 100755 index 0000000..d8bbd95 --- /dev/null +++ b/scripts/mc.sh @@ -0,0 +1,265 @@ +#!/bin/bash +# Mission Control CLI - Main Entry Point +# Usage: ./mc.sh [args] +# +# Commands: +# auth Authentication (login, logout, session) +# task Task operations (delegates to gantt-board) +# project Project operations (delegates to gantt-board) +# sprint Sprint operations (delegates to gantt-board) +# search Search across tasks, projects, documents +# document Document management +# due-dates Tasks with due dates +# dashboard Dashboard data +# +# Environment: +# MC_API_URL Mission Control API URL (default: http://localhost:3001/api) +# GANTT_BOARD_DIR Path to gantt-board directory (auto-detected) +# MC_COOKIE_FILE Path to cookie file (default: ~/.config/mission-control/cookies.txt) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export SCRIPT_DIR + +# Source API client library +source "$SCRIPT_DIR/lib/api_client.sh" + +# Auto-detect gantt-board directory +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + # Try common locations + if [[ -d "$SCRIPT_DIR/../../../gantt-board" ]]; then + GANTT_BOARD_DIR="$SCRIPT_DIR/../../../gantt-board" + elif [[ -d "$HOME/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="$HOME/Documents/Projects/OpenClaw/Web/gantt-board" + elif [[ -d "/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" + fi +fi + +# Show usage +usage() { + cat << 'EOF' +Mission Control CLI + +Usage: ./mc.sh [args] + +Commands: + auth Authentication operations + login Login to Mission Control + logout Logout + session Show current session + + task Task operations (delegates to gantt-board) + See: ./task.sh --help + + project Project operations (delegates to gantt-board) + See: ./project.sh --help + + sprint Sprint operations (delegates to gantt-board) + See: ./sprint.sh --help + + search Search across tasks, projects, documents + + due-dates List tasks with due dates + + documents Document management + list List all documents + get Get document by ID + + dashboard Get dashboard data + +Examples: + ./mc.sh auth login user@example.com password + ./mc.sh search "api design" + ./mc.sh due-dates + ./mc.sh task list --status open + ./mc.sh project list + +Environment Variables: + MC_API_URL Mission Control API URL (default: http://localhost:3001/api) + GANTT_BOARD_DIR Path to gantt-board directory + MC_COOKIE_FILE Path to cookie file + +EOF +} + +# Auth commands +handle_auth() { + local subcmd="${1:-}" + + case "$subcmd" in + login) + local email="${2:-}" + local password="${3:-}" + if [[ -z "$email" || -z "$password" ]]; then + echo "Usage: ./mc.sh auth login " >&2 + exit 1 + fi + mc_login "$email" "$password" + ;; + logout) + mc_logout + ;; + session) + mc_get "/auth/session" | jq . + ;; + *) + echo "Unknown auth subcommand: $subcmd" >&2 + echo "Usage: ./mc.sh auth {login|logout|session}" >&2 + exit 1 + ;; + esac +} + +# Search command +handle_search() { + local query="${1:-}" + + if [[ -z "$query" ]]; then + echo "Usage: ./mc.sh search " >&2 + exit 1 + fi + + local encoded_query + encoded_query=$(url_encode "$query") + mc_get "/search?q=$encoded_query" | jq . +} + +# Due dates command +handle_due_dates() { + mc_get "/tasks/with-due-dates" | jq . +} + +# Documents command +handle_documents() { + local subcmd="${1:-list}" + + case "$subcmd" in + list) + mc_get "/documents" | jq . + ;; + get) + local id="${2:-}" + if [[ -z "$id" ]]; then + echo "Usage: ./mc.sh documents get " >&2 + exit 1 + fi + mc_get "/documents?id=$id" | jq . + ;; + *) + echo "Unknown documents subcommand: $subcmd" >&2 + echo "Usage: ./mc.sh documents {list|get }" >&2 + exit 1 + ;; + esac +} + +# Task command - delegate to gantt-board +handle_task() { + if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + echo "Error: GANTT_BOARD_DIR not set and gantt-board not found" >&2 + echo "Please set GANTT_BOARD_DIR to the path of your gantt-board installation" >&2 + exit 1 + fi + + if [[ ! -f "$GANTT_BOARD_DIR/scripts/task.sh" ]]; then + echo "Error: gantt-board task.sh not found at $GANTT_BOARD_DIR/scripts/task.sh" >&2 + exit 1 + fi + + # Delegate to gantt-board task.sh + exec "$GANTT_BOARD_DIR/scripts/task.sh" "$@" +} + +# Project command - delegate to gantt-board +handle_project() { + if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + echo "Error: GANTT_BOARD_DIR not set and gantt-board not found" >&2 + exit 1 + fi + + if [[ ! -f "$GANTT_BOARD_DIR/scripts/project.sh" ]]; then + echo "Error: gantt-board project.sh not found at $GANTT_BOARD_DIR/scripts/project.sh" >&2 + exit 1 + fi + + exec "$GANTT_BOARD_DIR/scripts/project.sh" "$@" +} + +# Sprint command - delegate to gantt-board +handle_sprint() { + if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + echo "Error: GANTT_BOARD_DIR not set and gantt-board not found" >&2 + exit 1 + fi + + if [[ ! -f "$GANTT_BOARD_DIR/scripts/sprint.sh" ]]; then + echo "Error: gantt-board sprint.sh not found at $GANTT_BOARD_DIR/scripts/sprint.sh" >&2 + exit 1 + fi + + exec "$GANTT_BOARD_DIR/scripts/sprint.sh" "$@" +} + +# Dashboard command +handle_dashboard() { + # For now, combine multiple API calls to build dashboard view + echo "Fetching dashboard data..." + echo "" + + echo "=== Tasks with Due Dates ===" + handle_due_dates | jq -r '.[] | "\(.due_date) | \(.priority) | \(.title)"' 2>/dev/null || echo "No tasks with due dates" + + echo "" + echo "=== Recent Activity ===" + # This would need a dedicated API endpoint + echo "(Recent activity endpoint not yet implemented)" +} + +# Main command dispatcher +main() { + local cmd="${1:-}" + shift || true + + case "$cmd" in + auth) + handle_auth "$@" + ;; + search) + handle_search "$@" + ;; + due-dates) + handle_due_dates + ;; + documents|document) + handle_documents "$@" + ;; + task) + handle_task "$@" + ;; + project) + handle_project "$@" + ;; + sprint) + handle_sprint "$@" + ;; + dashboard) + handle_dashboard + ;; + help|--help|-h) + usage + ;; + *) + if [[ -z "$cmd" ]]; then + usage + exit 1 + fi + echo "Unknown command: $cmd" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/project.sh b/scripts/project.sh index b0af2eb..139b567 100755 --- a/scripts/project.sh +++ b/scripts/project.sh @@ -1,9 +1,39 @@ #!/bin/bash +# Mission Control Project CLI Wrapper +# Delegates to gantt-board project.sh for project operations +# This maintains the architecture principle: CLI is passthrough to API set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=./lib/gantt_cli.sh -source "$SCRIPT_DIR/lib/gantt_cli.sh" -run_gantt_cli "project.sh" "$@" +# Auto-detect gantt-board directory if not set +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + # Try common locations + if [[ -d "$SCRIPT_DIR/../../../gantt-board" ]]; then + GANTT_BOARD_DIR="$SCRIPT_DIR/../../../gantt-board" + elif [[ -d "$HOME/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="$HOME/Documents/Projects/OpenClaw/Web/gantt-board" + elif [[ -d "/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" + fi +fi + +# Verify gantt-board is available +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + echo "Error: GANTT_BOARD_DIR not set and gantt-board not found" >&2 + echo "" >&2 + echo "Please set GANTT_BOARD_DIR to the path of your gantt-board installation:" >&2 + echo " export GANTT_BOARD_DIR=/path/to/gantt-board" >&2 + exit 1 +fi + +if [[ ! -f "$GANTT_BOARD_DIR/scripts/project.sh" ]]; then + echo "Error: gantt-board project.sh not found at $GANTT_BOARD_DIR/scripts/project.sh" >&2 + echo "" >&2 + echo "Please ensure gantt-board is installed correctly." >&2 + exit 1 +fi + +# Delegate all calls to gantt-board project.sh +exec "$GANTT_BOARD_DIR/scripts/project.sh" "$@" diff --git a/scripts/sprint.sh b/scripts/sprint.sh new file mode 100755 index 0000000..0eab080 --- /dev/null +++ b/scripts/sprint.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Mission Control Sprint CLI Wrapper +# Delegates to gantt-board sprint.sh for sprint operations +# This maintains the architecture principle: CLI is passthrough to API + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Auto-detect gantt-board directory if not set +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + # Try common locations + if [[ -d "$SCRIPT_DIR/../../../gantt-board" ]]; then + GANTT_BOARD_DIR="$SCRIPT_DIR/../../../gantt-board" + elif [[ -d "$HOME/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="$HOME/Documents/Projects/OpenClaw/Web/gantt-board" + elif [[ -d "/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" + fi +fi + +# Verify gantt-board is available +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + echo "Error: GANTT_BOARD_DIR not set and gantt-board not found" >&2 + echo "" >&2 + echo "Please set GANTT_BOARD_DIR to the path of your gantt-board installation:" >&2 + echo " export GANTT_BOARD_DIR=/path/to/gantt-board" >&2 + exit 1 +fi + +if [[ ! -f "$GANTT_BOARD_DIR/scripts/sprint.sh" ]]; then + echo "Error: gantt-board sprint.sh not found at $GANTT_BOARD_DIR/scripts/sprint.sh" >&2 + echo "" >&2 + echo "Please ensure gantt-board is installed correctly." >&2 + exit 1 +fi + +# Delegate all calls to gantt-board sprint.sh +exec "$GANTT_BOARD_DIR/scripts/sprint.sh" "$@" diff --git a/scripts/task.sh b/scripts/task.sh index 8dcc652..9bcc1a4 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -1,9 +1,39 @@ #!/bin/bash +# Mission Control Task CLI Wrapper +# Delegates to gantt-board task.sh for task operations +# This maintains the architecture principle: CLI is passthrough to API set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=./lib/gantt_cli.sh -source "$SCRIPT_DIR/lib/gantt_cli.sh" -run_gantt_cli "task.sh" "$@" +# Auto-detect gantt-board directory if not set +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + # Try common locations + if [[ -d "$SCRIPT_DIR/../../../gantt-board" ]]; then + GANTT_BOARD_DIR="$SCRIPT_DIR/../../../gantt-board" + elif [[ -d "$HOME/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="$HOME/Documents/Projects/OpenClaw/Web/gantt-board" + elif [[ -d "/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" ]]; then + GANTT_BOARD_DIR="/Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board" + fi +fi + +# Verify gantt-board is available +if [[ -z "${GANTT_BOARD_DIR:-}" ]]; then + echo "Error: GANTT_BOARD_DIR not set and gantt-board not found" >&2 + echo "" >&2 + echo "Please set GANTT_BOARD_DIR to the path of your gantt-board installation:" >&2 + echo " export GANTT_BOARD_DIR=/path/to/gantt-board" >&2 + exit 1 +fi + +if [[ ! -f "$GANTT_BOARD_DIR/scripts/task.sh" ]]; then + echo "Error: gantt-board task.sh not found at $GANTT_BOARD_DIR/scripts/task.sh" >&2 + echo "" >&2 + echo "Please ensure gantt-board is installed correctly." >&2 + exit 1 +fi + +# Delegate all calls to gantt-board task.sh +exec "$GANTT_BOARD_DIR/scripts/task.sh" "$@"