Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-25 14:59:15 -06:00
parent 742bd61e7b
commit b1ee0da1b8
9 changed files with 923 additions and 181 deletions

29
app/api/activity/route.ts Normal file
View File

@ -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<ActivityPayload>("/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 });
}
}

View File

@ -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<Database["public"]["Tables"]["projects"]["Row"], "id" | "name" | "color">;
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<ActivityItem[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Task & {
projects: Pick<Project, "name" | "color"> | null;
assignee?: { id?: string; name?: string | null } | null;
}>;
const globalTaskUserNameById: Record<string, string> = {};
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<string, string> = {};
@ -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]);

View File

@ -64,15 +64,25 @@ export async function fetchGanttApi<T>(endpoint: string): Promise<T> {
},
});
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;
})();

240
scripts/README.md Normal file
View File

@ -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 <email> <password>
./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 <id>
```
### Task Operations (via Gantt Board)
```bash
./mc.sh task list [--status <status>] [--priority <priority>]
./mc.sh task get <task-id>
./mc.sh task create --title "..." [--description "..."] [--project <name>]
./mc.sh task update <task-id> [--status <status>] [--priority <priority>]
./mc.sh task delete <task-id>
```
See Gantt Board CLI documentation for full task command reference.
### Project Operations (via Gantt Board)
```bash
./mc.sh project list
./mc.sh project get <project-id-or-name>
./mc.sh project create --name "..." [--description "..."]
./mc.sh project update <project-id> [--name "..."] [--description "..."]
```
### Sprint Operations (via Gantt Board)
```bash
./mc.sh sprint list [--active]
./mc.sh sprint get <sprint-id-or-name>
./mc.sh sprint create --name "..." [--goal "..."]
./mc.sh sprint close <sprint-id-or-name>
```
## 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/<feature>/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/)

111
scripts/lib/api_client.sh Executable file
View File

@ -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 <method> <endpoint> [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 <endpoint>
mc_get() {
mc_api_call "GET" "$1"
}
# POST request helper
# Usage: mc_post <endpoint> [data]
mc_post() {
local endpoint="$1"
local data="${2:-}"
mc_api_call "POST" "$endpoint" "$data"
}
# DELETE request helper
# Usage: mc_delete <endpoint>
mc_delete() {
mc_api_call "DELETE" "$1"
}
# URL encode a string
# Usage: url_encode <string>
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 <email> <password>
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

265
scripts/mc.sh Executable file
View File

@ -0,0 +1,265 @@
#!/bin/bash
# Mission Control CLI - Main Entry Point
# Usage: ./mc.sh <command> [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 <command> [args]
Commands:
auth <subcommand> Authentication operations
login <email> <password> Login to Mission Control
logout Logout
session Show current session
task <args> Task operations (delegates to gantt-board)
See: ./task.sh --help
project <args> Project operations (delegates to gantt-board)
See: ./project.sh --help
sprint <args> Sprint operations (delegates to gantt-board)
See: ./sprint.sh --help
search <query> Search across tasks, projects, documents
due-dates List tasks with due dates
documents Document management
list List all documents
get <id> 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 <email> <password>" >&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 <query>" >&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 <id>" >&2
exit 1
fi
mc_get "/documents?id=$id" | jq .
;;
*)
echo "Unknown documents subcommand: $subcmd" >&2
echo "Usage: ./mc.sh documents {list|get <id>}" >&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 "$@"

View File

@ -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" "$@"

39
scripts/sprint.sh Executable file
View File

@ -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" "$@"

View File

@ -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" "$@"