diff --git a/skills/gantt-tasks/SKILL.md b/skills/gantt-tasks/SKILL.md new file mode 100644 index 0000000..7c9b066 --- /dev/null +++ b/skills/gantt-tasks/SKILL.md @@ -0,0 +1,357 @@ +--- +name: gantt-tasks +description: Full CRUD operations for Gantt Board tasks including comments, attachments, and sprint management. Uses Gantt Board API with machine token authentication. +--- + +# gantt-tasks + +**Module Skill** - Complete task management for Gantt Board via API. + +## Purpose + +Provides all operations needed to work with Gantt Board tasks: create, read, update, delete, comments, attachments, and sprint queries. Used by orchestrator skills and agents. + +## Authentication + +Uses **machine token** for server-to-server API calls: + +```bash +export GANTT_MACHINE_TOKEN="50cd5e8fe3f895353f97c9ee64052c0b689b4eedf79259746413734d0a163cf8" +export GANTT_API_URL="https://gantt-board.vercel.app/api" +``` + +**Architecture:** Skills → API (with machine token) → Database + +This follows the API-centric pattern where all business logic lives in API routes. + +## Usage + +```bash +source ~/.agents/skills/gantt-tasks/lib/tasks.sh + +# Create task +TASK_ID=$(task_create --title "Fix bug" --project "Gantt Board" --sprint current) + +# Add comment +task_add_comment "$TASK_ID" "Starting work on this" + +# Attach file +task_attach_file "$TASK_ID" "/path/to/file.md" +``` + +## Functions + +### Task CRUD + +#### `task_create([options])` + +Create a new task. + +**Options:** +- `--title` - Task title (required) +- `--description` - Task description (optional) +- `--type` - Task type: feature, bug, research, task (default: task) +- `--status` - Status: open, todo, in-progress, review, done (default: open) +- `--priority` - Priority: low, medium, high, critical (default: medium) +- `--project` - Project name or ID (optional, auto-detects "current") +- `--sprint` - Sprint name or ID, or "current" for current sprint (optional) +- `--assignee` - Assignee name or ID (default: Max) +- `--due-date` - Due date YYYY-MM-DD (optional) +- `--tags` - Tags as comma-separated string (optional) + +**Returns:** Task ID on success + +**Example:** +```bash +TASK_ID=$(task_create \ + --title "Research API options" \ + --description "Compare REST vs GraphQL" \ + --type research \ + --priority high \ + --sprint current \ + --tags "api,research") +``` + +#### `task_get(TASK_ID)` + +Get full task details including comments. + +**Parameters:** +- `TASK_ID` - Task UUID + +**Returns:** Task JSON + +**Example:** +```bash +task=$(task_get "a1b2c3d4-...") +echo "$task" | jq -r '.title' +``` + +#### `task_update(TASK_ID, [options])` + +Update task fields. + +**Parameters:** +- `TASK_ID` - Task UUID +- Options same as task_create (except project/sprint usually fixed) + +**Example:** +```bash +task_update "$TASK_ID" --status in-progress --priority critical +``` + +#### `task_delete(TASK_ID)` + +Delete a task (permanent). + +**Example:** +```bash +task_delete "$TASK_ID" +``` + +#### `task_list([filters])` + +List tasks with optional filters. + +**Filters:** +- `--status` - Filter by status (comma-separated: open,todo,in-progress) +- `--project` - Filter by project name/ID +- `--sprint` - Filter by sprint name/ID or "current" +- `--assignee` - Filter by assignee +- `--priority` - Filter by priority +- `--json` - Output as JSON + +**Example:** +```bash +# List current sprint tasks +task_list --sprint current --status open,todo,in-progress + +# List my high priority tasks +task_list --assignee Max --priority high +``` + +### Comments + +#### `task_add_comment(TASK_ID, TEXT, [OPTIONS])` + +Add a comment to a task. + +**Parameters:** +- `TASK_ID` - Task UUID +- `TEXT` - Comment text (supports markdown) +- `--checklist` - Include checklist format automatically +- `--status` - Add status prefix (in-progress, review, etc.) + +**Example:** +```bash +# Simple comment +task_add_comment "$TASK_ID" "Found the issue in line 42" + +# Checklist comment with status +task_add_comment "$TASK_ID" \ + "Fixed the bug\n\nNow testing..." \ + --checklist \ + --status in-progress +``` + +#### `task_update_comment(TASK_ID, COMMENT_ID, TEXT)` + +Update an existing comment (for progress tracking). + +**Parameters:** +- `TASK_ID` - Task UUID +- `COMMENT_ID` - Comment UUID to update +- `TEXT` - New comment text + +**Example:** +```bash +# Update progress comment +COMMENT_ID=$(echo "$TASK" | jq -r '.comments[-1].id') + +task_update_comment "$TASK_ID" "$COMMENT_ID" \ + "## $(date +%Y-%m-%d) 🔄 In Progress\n\n### ✅ Completed\n- [x] Phase 1\n\n### 🔄 In Progress\n- [ ] Phase 2" +``` + +**Auto-format:** +If `--checklist` is passed, formats as: +```markdown +## [YYYY-MM-DD HH:MM] 🔄 [STATUS] + +### ✅ Completed +- [x] Previous item + +### 🔄 In Progress +- [x] Current item: [TEXT] + +### 📋 Remaining +- [ ] Next step + +### 📊 Progress: X/Y tasks complete +``` + +#### `task_reply_to_comment(TASK_ID, COMMENT_ID, TEXT)` + +Reply to an existing comment. + +**Example:** +```bash +task_reply_to_comment "$TASK_ID" "$COMMENT_ID" "Great point! I'll check that." +``` + +### Attachments + +#### `task_attach_file(TASK_ID, FILE_PATH, [OPTIONS])` + +Attach a file to a task. + +**Parameters:** +- `TASK_ID` - Task UUID +- `FILE_PATH` - Path to file +- `--description` - Attachment description (optional) + +**Example:** +```bash +# Attach markdown file +task_attach_file "$TASK_ID" "/tmp/plan.md" --description "Implementation plan" + +# Attach any file type +task_attach_file "$TASK_ID" "/tmp/screenshot.png" +``` + +**Note:** Large files should be stored elsewhere and linked. + +### Sprint Management + +#### `sprint_get_current()` + +Get the current sprint (contains today's date). + +**Returns:** Sprint ID + +**Example:** +```bash +SPRINT_ID=$(sprint_get_current) +echo "Current sprint: $SPRINT_ID" +``` + +#### `sprint_list([OPTIONS])` + +List all sprints. + +**Options:** +- `--active` - Only active sprints +- `--project` - Filter by project + +**Example:** +```bash +sprint_list --active +``` + +### Project Resolution + +#### `resolve_project_id(PROJECT_NAME)` + +Convert project name to ID. + +**Example:** +```bash +PROJECT_ID=$(resolve_project_id "Gantt Board") +``` + +#### `resolve_user_id(USER_NAME)` + +Convert user name to ID. + +**Example:** +```bash +USER_ID=$(resolve_user_id "Max") +``` + +## File Structure + +``` +~/.agents/skills/gantt-tasks/ +├── SKILL.md # This documentation +└── lib/ + └── tasks.sh # All task functions +``` + +## Integration Example + +Complete workflow: + +```bash +# Set authentication +export GANTT_MACHINE_TOKEN="50cd5e8fe3f895353f97c9ee64052c0b689b4eedf79259746413734d0a163cf8" +export GANTT_API_URL="https://gantt-board.vercel.app/api" + +source ~/.agents/skills/gantt-tasks/lib/tasks.sh + +# 1. Create task +TASK_ID=$(task_create \ + --title "Research URL extraction options" \ + --type research \ + --priority high \ + --sprint current) + +echo "Created task: $TASK_ID" + +# 2. Add initial comment +task_add_comment "$TASK_ID" "Starting research phase" --checklist + +# 3. Update with progress +task_update "$TASK_ID" --status in-progress +task_add_comment "$TASK_ID" "Evaluated 3 options:\n1. Scrapling\n2. Tavily\n3. web_fetch" \ + --checklist \ + --status in-progress + +# 4. Attach recommendation +cat > /tmp/recommendation.md << 'EOF' +## Recommendation + +Use Scrapling as primary, Tavily as fallback. + +- Scrapling handles JavaScript sites +- Tavily fast for articles +- web_fetch for simple sites +EOF + +task_attach_file "$TASK_ID" /tmp/recommendation.md --description "Research findings" + +# 5. Mark ready for review +task_update "$TASK_ID" --status review +task_add_comment "$TASK_ID" "Research complete. Recommendation attached." \ + --checklist + +echo "Task ready for review: https://gantt-board.vercel.app/tasks/$TASK_ID" +``` + +## CLI Alternative + +For interactive/command-line use, use the Gantt Board CLI: + +```bash +# Set auth for CLI +export GANTT_MACHINE_TOKEN="50cd5e8fe3f895353f97c9ee64052c0b689b4eedf79259746413734d0a163cf8" +export API_URL="https://gantt-board.vercel.app/api" + +cd /Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board + +# Create task +./scripts/gantt.sh task create "Title" --type research --sprint current + +# List tasks +./scripts/gantt.sh task list --sprint current --status in-progress + +# Get task +./scripts/gantt.sh task get $TASK_ID + +# Update task +./scripts/gantt.sh task update $TASK_ID --status review +``` + +**Note:** The skill library and CLI both use the same API endpoints with machine token authentication. + +--- + +**Note:** This is a low-level module skill. Used by orchestrator skills, not directly by users. diff --git a/skills/gantt-tasks/lib/tasks.sh b/skills/gantt-tasks/lib/tasks.sh new file mode 100644 index 0000000..411d1d1 --- /dev/null +++ b/skills/gantt-tasks/lib/tasks.sh @@ -0,0 +1,495 @@ +#!/bin/bash +# gantt-tasks library - Full CRUD for Gantt Board tasks +# Location: ~/.agents/skills/gantt-tasks/lib/tasks.sh +# Refactored to use API endpoints with machine token auth + +# Configuration +GANTT_API_URL="${GANTT_API_URL:-https://gantt-board.vercel.app/api}" +GANTT_MACHINE_TOKEN="${GANTT_MACHINE_TOKEN:-}" +MAX_ID="9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa" # Max user ID + +# Debug function +debug() { + echo "[DEBUG] $*" >&2 +} + +# Error handler +error_exit() { + echo "❌ $1" >&2 + return 1 +} + +# Make API call with machine token +_api_call() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + if [[ -z "$GANTT_MACHINE_TOKEN" ]]; then + error_exit "GANTT_MACHINE_TOKEN not set" + return 1 + fi + + local url="${GANTT_API_URL}${endpoint}" + local curl_opts=( + -s + -H "Content-Type: application/json" + -H "Authorization: Bearer ${GANTT_MACHINE_TOKEN}" + ) + + if [[ -n "$data" ]]; then + curl_opts+=(-d "$data") + fi + + curl "${curl_opts[@]}" -X "$method" "$url" 2>/dev/null +} + +# Resolve project name to ID +resolve_project_id() { + local PROJECT_NAME="$1" + + # Special case: Research Project + if [[ "$PROJECT_NAME" == "Research" || "$PROJECT_NAME" == "Research Project" ]]; then + echo "a1b2c3d4-0003-0000-0000-000000000003" + return 0 + fi + + # Query by name via API + local RESULT=$(_api_call "GET" "/projects" | jq -r --arg q "$PROJECT_NAME" ' + .projects + | map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase))) + | .[0].id // empty + ') + + if [[ -n "$RESULT" ]]; then + echo "$RESULT" + return 0 + fi + + # Return empty if not found + echo "" + return 1 +} + +# Resolve user name to ID +resolve_user_id() { + local USER_NAME="$1" + + # Current mappings + case "$USER_NAME" in + "Max"|"max") echo "9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa" ;; + "Matt"|"matt"|"Matt Bruce") echo "0a3e400c-3932-48ae-9b65-f3f9c6f26fe9" ;; + *) echo "$MAX_ID" ;; # Default to Max + esac +} + +# Get current sprint (contains today's date) +sprint_get_current() { + local TODAY=$(date +%Y-%m-%d) + + # Get current sprint via API + local RESULT=$(_api_call "GET" "/sprints" | jq -r --arg today "$TODAY" ' + .sprints + | map(select(.startDate <= $today and .endDate >= $today)) + | .[0].id // empty + ') + + if [[ -n "$RESULT" && "$RESULT" != "null" ]]; then + echo "$RESULT" + return 0 + fi + + # Fallback: get most recent sprint + RESULT=$(_api_call "GET" "/sprints" | jq -r ' + .sprints + | sort_by(.startDate) + | reverse + | .[0].id // empty + ') + + echo "$RESULT" +} + +# Resolve sprint name/ID +resolve_sprint_id() { + local SPRINT="$1" + + if [[ "$SPRINT" == "current" ]]; then + sprint_get_current + return 0 + fi + + # If it looks like a UUID, use it directly + if [[ "$SPRINT" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then + echo "$SPRINT" + return 0 + fi + + # Query by name via API + local RESULT=$(_api_call "GET" "/sprints" | jq -r --arg q "$SPRINT" ' + .sprints + | map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase))) + | .[0].id // empty + ') + + echo "$RESULT" +} + +# Create a new task +task_create() { + local TITLE="" + local DESCRIPTION="" + local TYPE="task" + local STATUS="open" + local PRIORITY="medium" + local PROJECT="" + local SPRINT="" + local ASSIGNEE="Max" + local DUE_DATE="" + local TAGS="" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --title) TITLE="$2"; shift 2 ;; + --description) DESCRIPTION="$2"; shift 2 ;; + --type) TYPE="$2"; shift 2 ;; + --status) STATUS="$2"; shift 2 ;; + --priority) PRIORITY="$2"; shift 2 ;; + --project) PROJECT="$2"; shift 2 ;; + --sprint) SPRINT="$2"; shift 2 ;; + --assignee) ASSIGNEE="$2"; shift 2 ;; + --due-date) DUE_DATE="$2"; shift 2 ;; + --tags) TAGS="$2"; shift 2 ;; + *) shift ;; + esac + done + + # Validate + [[ -z "$TITLE" ]] && error_exit "--title is required" && return 1 + + # Resolve IDs + local PROJECT_ID="a1b2c3d4-0003-0000-0000-000000000003" + if [[ -n "$PROJECT" ]]; then + PROJECT_ID=$(resolve_project_id "$PROJECT") + fi + + local SPRINT_ID="" + if [[ -n "$SPRINT" ]]; then + SPRINT_ID=$(resolve_sprint_id "$SPRINT") + fi + + local ASSIGNEE_ID=$(resolve_user_id "$ASSIGNEE") + + # Build JSON payload + local JSONPayload=$(jq -n \ + --arg title "$TITLE" \ + --arg type "$TYPE" \ + --arg status "$STATUS" \ + --arg priority "$PRIORITY" \ + --arg assigneeId "$ASSIGNEE_ID" \ + --arg projectId "$PROJECT_ID" \ + '{ + title: $title, + type: $type, + status: $status, + priority: $priority, + assigneeId: $assigneeId, + projectId: $projectId, + comments: [], + tags: [] + }') + + # Add optional fields + [[ -n "$DESCRIPTION" ]] && JSONPayload=$(echo "$JSONPayload" | jq --arg desc "$DESCRIPTION" '. + {description: $desc}') + [[ -n "$SPRINT_ID" ]] && JSONPayload=$(echo "$JSONPayload" | jq --arg sid "$SPRINT_ID" '. + {sprintId: $sid}') + [[ -n "$DUE_DATE" ]] && JSONPayload=$(echo "$JSONPayload" | jq --arg dd "$DUE_DATE" '. + {dueDate: $dd}') + [[ -n "$TAGS" ]] && JSONPayload=$(echo "$JSONPayload" | jq --argjson tags "[$(echo "$TAGS" | sed 's/,/","/g' | sed 's/^/"/' | sed 's/$/"/')]" '. + {tags: $tags}') + + # Create task via API + local Response=$(_api_call "POST" "/tasks" "{\"task\": $JSONPayload}") + + local TaskId=$(echo "$Response" | jq -r '.task.id // .id // empty') + + if [[ -n "$TaskId" && "$TaskId" != "null" ]]; then + echo "$TaskId" + return 0 + fi + + error_exit "Failed to create task: $(echo "$Response" | jq -r '.error // "Unknown error"')" + return 1 +} + +# Get task details +task_get() { + local TASK_ID="$1" + + local Response=$(_api_call "GET" "/tasks") + echo "$Response" | jq --arg id "$TASK_ID" '.tasks | map(select(.id == $id)) | .[0] // empty' +} + +# List tasks with filters +task_list() { + local StatusFilter="" + local ProjectFilter="" + local SprintFilter="" + local AssigneeFilter="" + local PriorityFilter="" + local JsonOutput=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --status) StatusFilter="$2"; shift 2 ;; + --project) ProjectFilter="$2"; shift 2 ;; + --sprint) SprintFilter="$2"; shift 2 ;; + --assignee) AssigneeFilter="$2"; shift 2 ;; + --priority) PriorityFilter="$2"; shift 2 ;; + --json) JsonOutput=true; shift ;; + *) shift ;; + esac + done + + # Get all tasks via API + local Response=$(_api_call "GET" "/tasks") + local Tasks=$(echo "$Response" | jq '.tasks // []') + + # Apply filters in jq + local Filtered="$Tasks" + + if [[ -n "$StatusFilter" ]]; then + # Handle comma-separated status values + if [[ "$StatusFilter" =~ , ]]; then + Filtered=$(echo "$Filtered" | jq --arg statuses "$StatusFilter" ' + [split(",")] as $statusList | map(select(.status as $s | $statusList | index($s))) + ') + else + Filtered=$(echo "$Filtered" | jq --arg status "$StatusFilter" 'map(select(.status == $status))') + fi + fi + + if [[ -n "$ProjectFilter" ]]; then + local ProjectId=$(resolve_project_id "$ProjectFilter") + [[ -n "$ProjectId" ]] && Filtered=$(echo "$Filtered" | jq --arg pid "$ProjectId" 'map(select(.projectId == $pid))') + fi + + if [[ -n "$SprintFilter" ]]; then + local SprintId=$(resolve_sprint_id "$SprintFilter") + [[ -n "$SprintId" ]] && Filtered=$(echo "$Filtered" | jq --arg sid "$SprintId" 'map(select(.sprintId == $sid))') + fi + + if [[ -n "$AssigneeFilter" ]]; then + local AssigneeId=$(resolve_user_id "$AssigneeFilter") + Filtered=$(echo "$Filtered" | jq --arg aid "$AssigneeId" 'map(select(.assigneeId == $aid))') + fi + + if [[ -n "$PriorityFilter" ]]; then + Filtered=$(echo "$Filtered" | jq --arg pri "$PriorityFilter" 'map(select(.priority == $pri))') + fi + + if [[ "$JsonOutput" == true ]]; then + echo "{\"tasks\": $Filtered}" + else + # Pretty print + echo "$Filtered" | jq -r '.[] | "\(.status) | \(.priority) | \(.title) | \(.id)"' 2>/dev/null + fi +} + +# Update task +task_update() { + local TASK_ID="$1" + shift + + # First get the current task + local CurrentTask=$(task_get "$TASK_ID") + [[ -z "$CurrentTask" ]] && error_exit "Task not found: $TASK_ID" && return 1 + + # Build updates + local Updates="{}" + + while [[ $# -gt 0 ]]; do + case $1 in + --title) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {title: $v}'); shift 2 ;; + --description) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {description: $v}'); shift 2 ;; + --status) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {status: $v}'); shift 2 ;; + --priority) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {priority: $v}'); shift 2 ;; + --due-date) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {dueDate: $v}'); shift 2 ;; + *) shift ;; + esac + done + + # Merge current task with updates + local MergedTask=$(echo "$CurrentTask" | jq --argjson updates "$Updates" '. + $updates') + + # Send update via API + local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') + local Response=$(_api_call "POST" "/tasks" "$Payload") + + if echo "$Response" | jq -e '.error' >/dev/null 2>&1; then + error_exit "Update failed: $(echo "$Response" | jq -r '.error')" + return 1 + fi + + echo "✅ Task updated" +} + +# Delete task +task_delete() { + local TASK_ID="$1" + + _api_call "DELETE" "/tasks" "{\"id\": \"$TASK_ID\"}" >/dev/null + + echo "✅ Task deleted" +} + +# Add comment to task +task_add_comment() { + local TASK_ID="$1" + local TEXT="$2" + local UseChecklist=false + local StatusPrefix="" + + shift 2 + + while [[ $# -gt 0 ]]; do + case $1 in + --checklist) UseChecklist=true; shift ;; + --status) StatusPrefix="$2"; shift 2 ;; + *) shift ;; + esac + done + + # Format comment + local Now=$(date +"%Y-%m-%d %H:%M") + local StatusEmoji="🔄" + [[ -n "$StatusPrefix" ]] && StatusEmoji="$StatusPrefix" + + local CommentText + if [[ "$UseChecklist" == true ]]; then + CommentText=$(cat </dev/null || date +%s)" \ + --arg text "$CommentText" \ + --arg author "$MAX_ID" \ + '{ + id: $id, + text: $text, + createdAt: now | todate, + commentAuthorId: $author, + replies: [] + }') + + # Append to comments + local UpdatedComments=$(echo "$CurrentComments" | jq --argjson new "$NewComment" '. + [$new]') + + # Merge and update task + local MergedTask=$(echo "$CurrentTask" | jq --argjson comments "$UpdatedComments" '. + {comments: $comments}') + local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') + + _api_call "POST" "/tasks" "$Payload" >/dev/null + + echo "✅ Comment added" +} + +# Attach file to task (stores content in attachments field) +task_attach_file() { + local TASK_ID="$1" + local FILE_PATH="$2" + local Description="${3:-Attached file}" + + [[ ! -f "$FILE_PATH" ]] && error_exit "File not found: $FILE_PATH" && return 1 + + # Read file content + local Content=$(cat "$FILE_PATH") + local FileName=$(basename "$FILE_PATH") + + # Get current task with attachments + local CurrentTask=$(task_get "$TASK_ID") + local CurrentAttachments=$(echo "$CurrentTask" | jq '.attachments // []') + + # Build new attachment + local NewAttachment=$(jq -n \ + --arg id "$(uuidgen 2>/dev/null || date +%s)" \ + --arg name "$FileName" \ + --arg type "$(file -b --mime-type "$FILE_PATH" 2>/dev/null || echo 'application/octet-stream')" \ + --arg dataUrl "data:text/plain;base64,$(base64 -i "$FILE_PATH" | tr -d '\n')" \ + '{ + id: $id, + name: $name, + type: $type, + dataUrl: $dataUrl, + uploadedAt: now | todate + }') + + # Append to attachments + local UpdatedAttachments=$(echo "$CurrentAttachments" | jq --argjson new "$NewAttachment" '. + [$new]') + + # Merge and update task + local MergedTask=$(echo "$CurrentTask" | jq --argjson attachments "$UpdatedAttachments" '. + {attachments: $attachments}') + local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') + + _api_call "POST" "/tasks" "$Payload" >/dev/null + + echo "✅ File attached: $FileName" +} + +# Update an existing comment by ID +task_update_comment() { + local TASK_ID="$1" + local COMMENT_ID="$2" + local NEW_TEXT="$3" + + # Get current task + local CurrentTask=$(task_get "$TASK_ID") + local AllComments=$(echo "$CurrentTask" | jq '.comments // []') + + # Find and update the specific comment, keep others unchanged + local UpdatedComments=$(echo "$AllComments" | jq --arg cid "$COMMENT_ID" --arg text "$NEW_TEXT" \ + 'map(if .id == $cid then .text = $text else . end)') + + # Merge and update task + local MergedTask=$(echo "$CurrentTask" | jq --argjson comments "$UpdatedComments" '. + {comments: $comments}') + local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') + + _api_call "POST" "/tasks" "$Payload" >/dev/null + + echo "✅ Comment updated" +} + +# Export all functions +export -f resolve_project_id +export -f resolve_user_id +export -f sprint_get_current +export -f resolve_sprint_id +export -f task_create +export -f task_get +export -f task_list +export -f task_update +export -f task_delete +export -f task_add_comment +export -f task_update_comment +export -f task_attach_file +export -f _api_call diff --git a/skills/mission-control-docs/SKILL.md b/skills/mission-control-docs/SKILL.md new file mode 100644 index 0000000..6dce08d --- /dev/null +++ b/skills/mission-control-docs/SKILL.md @@ -0,0 +1,267 @@ +--- +name: mission-control-docs +description: Full CRUD operations for Mission Control Documents. Create, read, update, delete documents. Uses Mission Control API with machine token authentication. +--- + +# mission-control-docs + +**Module Skill** - Complete document management for Mission Control via API. + +## Purpose + +Provides all operations needed to work with Mission Control documents: create, read, update, delete, list, and search. Uses Mission Control API (not direct Supabase). + +## Authentication + +Uses **machine token** for server-to-server API calls: + +```bash +export MC_MACHINE_TOKEN="50cd5e8fe3f895353f97c9ee64052c0b689b4eedf79259746413734d0a163cf8" +export MC_API_URL="https://mission-control-rho-pink.vercel.app/api" +``` + +**Architecture:** Skills → API (with machine token) → Database + +This follows the API-centric pattern where all business logic lives in API routes. + +## Usage + +```bash +source ~/.agents/skills/mission-control-docs/lib/docs.sh + +# Create document +DOC_ID=$(mc_doc_create \ + --title "Article Title" \ + --content "$CONTENT" \ + --folder "Research/AI & Agents/") + +# Get document +mc_doc_get "$DOC_ID" + +# List documents +mc_doc_list --folder "Research/" +``` + +## Functions + +### Document CRUD + +#### `mc_doc_create([options])` + +Create a new document. + +**Options:** +- `--title` - Document title (required) +- `--content` - Document content (required) +- `--folder` - Folder path (default: auto-detected) +- `--tags` - Tags as JSON array string (default: auto-detected) +- `--type` - Document type (default: markdown) +- `--description` - Description (optional) + +**Returns:** Document ID on success + +**Example:** +```bash +DOC_ID=$(mc_doc_create \ + --title "API Design Patterns" \ + --content "$SUMMARY_CONTENT" \ + --folder "Research/Tools & Tech/" \ + --tags '["api", "design", "reference"]') +``` + +**Auto-categorization:** If folder not specified, detects based on content keywords: +- `Research/AI & Agents/` - AI, agent, LLM, Claude, OpenClaw, GPT +- `Research/iOS Development/` - Swift, iOS, Xcode, Apple, SwiftUI +- `Research/Business & Marketing/` - SaaS, business, startup, marketing +- `Research/Tools & Tech/` - tool, library, API, SDK, framework +- `Research/Tutorials/` - tutorial, guide, how-to, walkthrough +- `Research/` - Default + +#### `mc_doc_get(DOC_ID)` + +Get document by ID. + +**Returns:** Document JSON + +**Example:** +```bash +DOC=$(mc_doc_get "a1b2c3d4-...") +echo "$DOC" | jq -r '.title' +``` + +#### `mc_doc_list([filters])` + +List documents with optional filters. + +**Filters:** +- `--folder` - Filter by folder path +- `--tags` - Filter by tag +- `--search` - Search in title/content +- `--limit` - Max results (default: 50) +- `--json` - Output as JSON + +**Example:** +```bash +# List all in folder +mc_doc_list --folder "Research/AI & Agents/" + +# Search +mc_doc_list --search "OpenClaw" + +# Get JSON for processing +mc_doc_list --folder "Research/" --json +``` + +#### `mc_doc_update(DOC_ID, [options])` + +Update document fields. + +**Parameters:** +- `DOC_ID` - Document UUID +- `--title` - New title +- `--content` - New content +- `--folder` - New folder +- `--tags` - New tags (JSON array) +- `--description` - New description + +**Example:** +```bash +mc_doc_update "$DOC_ID" \ + --title "Updated Title" \ + --tags '["research", "ai", "updated"]' +``` + +#### `mc_doc_delete(DOC_ID)` + +Delete document permanently. + +**Example:** +```bash +mc_doc_delete "$DOC_ID" +``` + +### Search & Discovery + +#### `mc_doc_search(QUERY, [LIMIT])` + +Search documents by title or content. + +**Parameters:** +- `QUERY` - Search term +- `LIMIT` - Max results (default: 20) + +**Example:** +```bash +# Search for OpenClaw +mc_doc_search "OpenClaw" 10 +``` + +#### `mc_doc_folder_list()` + +List all unique folders. + +**Example:** +```bash +mc_doc_folder_list +``` + +### Auto-detection + +#### `_detect_folder(CONTENT)` + +Auto-detect folder based on content keywords. + +**Example:** +```bash +FOLDER=$(_detect_folder "$CONTENT") +echo "Detected folder: $FOLDER" +``` + +#### `_detect_tags(CONTENT)` + +Auto-detect tags based on content keywords. + +**Returns:** JSON array of tags + +**Example:** +```bash +TAGS=$(_detect_tags "$CONTENT") +echo "Detected tags: $TAGS" +``` + +## File Structure + +``` +~/.agents/skills/mission-control-docs/ +├── SKILL.md # This documentation +└── lib/ + └── docs.sh # All document functions +``` + +## Integration Example + +Complete workflow: + +```bash +# Set authentication +export MC_MACHINE_TOKEN="50cd5e8fe3f895353f97c9ee64052c0b689b4eedf79259746413734d0a163cf8" +export MC_API_URL="https://mission-control-rho-pink.vercel.app/api" + +source ~/.agents/skills/mission-control-docs/lib/docs.sh + +# Research summary from URL +SUMMARY=$(cat << 'EOF' +## Scrapling: Modern Web Scraping Framework + +Scrapling is a Python web scraping framework with: +- Adaptive parsing +- Anti-bot bypass +- Full crawling capabilities + +**Use Cases:** +- E-commerce monitoring +- Content aggregation +- SEO auditing + +**Links:** +- GitHub: https://github.com/D4Vinci/Scrapling +- Docs: https://scrapling.readthedocs.io +EOF +) + +# Create document with auto-detection +DOC_ID=$(mc_doc_create \ + --title "Scrapling: Web Scraping Framework" \ + --content "$SUMMARY" \ + --description "Auto-research from URL: https://github.com/D4Vinci/Scrapling") + +echo "Document created: $DOC_ID" + +# Verify +echo "=== Document Created ===" +mc_doc_get "$DOC_ID" | jq -r '.title, .folder, .tags' +``` + +## CLI Alternative + +For interactive/command-line use, use the Mission Control CLI: + +```bash +# Set auth for CLI +export MC_MACHINE_TOKEN="50cd5e8fe3f895353f97c9ee64052c0b689b4eedf79259746413734d0a163cf8" +export MC_API_URL="https://mission-control-rho-pink.vercel.app/api" + +cd /Users/mattbruce/Documents/Projects/OpenClaw/Web/mission-control + +# List documents +./scripts/mc.sh document list + +# Get specific document +./scripts/mc.sh document get $DOC_ID +``` + +**Note:** The skill library and CLI both use the same API endpoints with machine token authentication. + +--- + +**Note:** This is a low-level module skill. Used by orchestrator skills like `url-research-to-documents-and-tasks`, not directly by users. diff --git a/skills/mission-control-docs/lib/docs.sh b/skills/mission-control-docs/lib/docs.sh new file mode 100644 index 0000000..1578883 --- /dev/null +++ b/skills/mission-control-docs/lib/docs.sh @@ -0,0 +1,287 @@ +#!/bin/bash +# mission-control-docs library - Full CRUD for Mission Control documents +# Location: ~/.agents/skills/mission-control-docs/lib/docs.sh +# Refactored to use API endpoints with machine token auth + +# Configuration +MC_API_URL="${MC_API_URL:-https://mission-control-rho-pink.vercel.app/api}" +MC_MACHINE_TOKEN="${MC_MACHINE_TOKEN:-}" + +# Error handler +_error_exit() { + echo "❌ $1" >&2 + return 1 +} + +# Make API call with machine token +_api_call() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + if [[ -z "$MC_MACHINE_TOKEN" ]]; then + _error_exit "MC_MACHINE_TOKEN not set" + return 1 + fi + + local url="${MC_API_URL}${endpoint}" + local curl_opts=( + -s + -H "Content-Type: application/json" + -H "Authorization: Bearer ${MC_MACHINE_TOKEN}" + ) + + if [[ -n "$data" ]]; then + curl_opts+=(-d "$data") + fi + + curl "${curl_opts[@]}" -X "$method" "$url" 2>/dev/null +} + +# Detect folder based on content keywords (auto-categorization) +_detect_folder() { + local CONTENT="$1" + local FOLDER="Research/" + + local LOWER_CONTENT=$(echo "$CONTENT" | tr '[:upper:]' '[:lower:]') + + if [[ "$LOWER_CONTENT" =~ (ai|agent|llm|claude|openclaw|machine learning|gpt|model) ]]; then + FOLDER="Research/AI & Agents/" + elif [[ "$LOWER_CONTENT" =~ (swift|ios|xcode|apple|app store|uikit|swiftui) ]]; then + FOLDER="Research/iOS Development/" + elif [[ "$LOWER_CONTENT" =~ (saas|business|startup|marketing|revenue|growth|sales) ]]; then + FOLDER="Research/Business & Marketing/" + elif [[ "$LOWER_CONTENT" =~ (tool|library|api|package|sdk|framework|npm|pip) ]]; then + FOLDER="Research/Tools & Tech/" + elif [[ "$LOWER_CONTENT" =~ (tutorial|guide|how.to|walkthrough|step.by.step) ]]; then + FOLDER="Research/Tutorials/" + fi + + echo "$FOLDER" +} + +# Detect tags based on content keywords +_detect_tags() { + local CONTENT="$1" + local TAGS=("saved" "article") + + local LOWER_CONTENT=$(echo "$CONTENT" | tr '[:upper:]' '[:lower:]') + + [[ "$LOWER_CONTENT" =~ (ai|agent|automation|llm|model) ]] && TAGS+=("ai" "agents") + [[ "$LOWER_CONTENT" =~ openclaw ]] && TAGS+=("openclaw") + [[ "$LOWER_CONTENT" =~ (swift|ios) ]] && TAGS+=("ios" "swift") + [[ "$LOWER_CONTENT" =~ (business|saas) ]] && TAGS+=("business") + [[ "$LOWER_CONTENT" =~ tutorial ]] && TAGS+=("tutorial") + [[ "$LOWER_CONTENT" =~ (reference|documentation|docs) ]] && TAGS+=("reference") + + printf '%s\n' "${TAGS[@]}" | jq -R . | jq -s 'unique' +} + +# Create document +mc_doc_create() { + local TITLE="" + local CONTENT="" + local FOLDER="" + local TAGS="" + local TYPE="markdown" + local DESCRIPTION="" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --title) TITLE="$2"; shift 2 ;; + --content) CONTENT="$2"; shift 2 ;; + --folder) FOLDER="$2"; shift 2 ;; + --tags) TAGS="$2"; shift 2 ;; + --type) TYPE="$2"; shift 2 ;; + --description) DESCRIPTION="$2"; shift 2 ;; + *) shift ;; + esac + done + + # Validate + [[ -z "$TITLE" ]] && _error_exit "--title is required" && return 1 + [[ -z "$CONTENT" ]] && _error_exit "--content is required" && return 1 + + # Clean content - remove control characters that break JSON + local CLEAN_CONTENT=$(echo "$CONTENT" | tr -d '\000-\031' | sed 's/\\/\\\\/g') + + # Auto-detect folder/tags if not provided + [[ -z "$FOLDER" ]] && FOLDER=$(_detect_folder "$CONTENT") + [[ -z "$TAGS" ]] && TAGS=$(_detect_tags "$CONTENT") + [[ -z "$DESCRIPTION" ]] && DESCRIPTION="Created: $(date +%Y-%m-%d)" + + # Build JSON payload + local TAGS_JSON + if [[ -n "$TAGS" ]]; then + TAGS_JSON="$TAGS" + else + TAGS_JSON=$(_detect_tags "$CONTENT") + fi + + local PAYLOAD=$(jq -n \ + --arg title "$TITLE" \ + --arg content "$CLEAN_CONTENT" \ + --arg type "$TYPE" \ + --arg folder "$FOLDER" \ + --argjson tags "$TAGS_JSON" \ + --arg description "$DESCRIPTION" \ + '{ + title: $title, + content: $content, + type: $type, + folder: $folder, + tags: $tags, + description: $description + }') + + # Create via API + local RESPONSE=$(_api_call "POST" "/documents" "$PAYLOAD") + + local DOC_ID=$(echo "$RESPONSE" | jq -r '.id // .document?.id // empty') + + if [[ -n "$DOC_ID" && "$DOC_ID" != "null" ]]; then + echo "$DOC_ID" + return 0 + fi + + _error_exit "Failed to create document: $(echo "$RESPONSE" | jq -r '.error // "Unknown error"')" + return 1 +} + +# Get document by ID +mc_doc_get() { + local DOC_ID="$1" + + local RESPONSE=$(_api_call "GET" "/documents?id=$DOC_ID") + echo "$RESPONSE" | jq --arg id "$DOC_ID" '.documents | map(select(.id == $id)) | .[0] // empty' +} + +# List documents with filters +mc_doc_list() { + local FOLDER="" + local TAG="" + local SEARCH="" + local LIMIT=50 + local JSON_OUTPUT=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --folder) FOLDER="$2"; shift 2 ;; + --tags) TAG="$2"; shift 2 ;; + --search) SEARCH="$2"; shift 2 ;; + --limit) LIMIT="$2"; shift 2 ;; + --json) JSON_OUTPUT=true; shift ;; + *) shift ;; + esac + done + + # Get documents via API + local RESPONSE=$(_api_call "GET" "/documents") + local DOCUMENTS=$(echo "$RESPONSE" | jq '.documents // []') + + # Apply filters in jq + if [[ -n "$FOLDER" ]]; then + DOCUMENTS=$(echo "$DOCUMENTS" | jq --arg f "$FOLDER" 'map(select(.folder == $f))') + fi + + if [[ -n "$TAG" ]]; then + DOCUMENTS=$(echo "$DOCUMENTS" | jq --arg t "$TAG" 'map(select(.tags | index($t)))') + fi + + if [[ -n "$SEARCH" ]]; then + DOCUMENTS=$(echo "$DOCUMENTS" | jq --arg s "$SEARCH" 'map(select(.title | contains($s) or .content | contains($s)))') + fi + + # Apply limit + DOCUMENTS=$(echo "$DOCUMENTS" | jq --argjson limit "$LIMIT" '.[:$limit]') + + if [[ "$JSON_OUTPUT" == true ]]; then + echo "{\"documents\": $DOCUMENTS}" + else + echo "$DOCUMENTS" | jq -r '.[] | "\(.folder) | \(.title) | \(.id)"' 2>/dev/null + fi +} + +# Update document +mc_doc_update() { + local DOC_ID="$1" + shift + + # First get the current document + local CURRENT_DOC=$(mc_doc_get "$DOC_ID") + [[ -z "$CURRENT_DOC" ]] && _error_exit "Document not found: $DOC_ID" && return 1 + + # Build updates + local UPDATES="{}" + + while [[ $# -gt 0 ]]; do + case $1 in + --title) UPDATES=$(echo "$UPDATES" | jq --arg v "$2" '. + {title: $v}'); shift 2 ;; + --content) UPDATES=$(echo "$UPDATES" | jq --arg v "$2" '. + {content: $v}'); shift 2 ;; + --folder) UPDATES=$(echo "$UPDATES" | jq --arg v "$2" '. + {folder: $v}'); shift 2 ;; + --tags) UPDATES=$(echo "$UPDATES" | jq --argjson v "$2" '. + {tags: $v}'); shift 2 ;; + --description) UPDATES=$(echo "$UPDATES" | jq --arg v "$2" '. + {description: $v}'); shift 2 ;; + *) shift ;; + esac + done + + # Merge current document with updates + local MERGED_DOC=$(echo "$CURRENT_DOC" | jq --argjson updates "$UPDATES" '. + $updates') + + # Send update via API + local PAYLOAD=$(jq -n --arg id "$DOC_ID" --argjson doc "$MERGED_DOC" '{id: $id, document: $doc}') + local RESPONSE=$(_api_call "PATCH" "/documents" "$PAYLOAD") + + if echo "$RESPONSE" | jq -e '.error' >/dev/null 2>&1; then + _error_exit "Update failed: $(echo "$RESPONSE" | jq -r '.error')" + return 1 + fi + + echo "✅ Document updated" +} + +# Delete document +mc_doc_delete() { + local DOC_ID="$1" + + _api_call "DELETE" "/documents" "{\"id\": \"$DOC_ID\"}" >/dev/null + + echo "✅ Document deleted" +} + +# Search documents +mc_doc_search() { + local QUERY="$1" + local LIMIT="${2:-20}" + + # Get documents and filter client-side + local RESPONSE=$(_api_call "GET" "/documents") + + echo "$RESPONSE" | jq --arg q "$QUERY" --argjson limit "$LIMIT" ' + .documents + | map(select(.title | test($q; "i") or .content | test($q; "i"))) + | .[:$limit] + ' +} + +# List folders +mc_doc_folder_list() { + # Get all documents and extract unique folders + local RESPONSE=$(_api_call "GET" "/documents") + + echo "$RESPONSE" | jq -r '.documents[].folder' | sort -u +} + +# Export functions +export -f _detect_folder +export -f _detect_tags +export -f _api_call +export -f mc_doc_create +export -f mc_doc_get +export -f mc_doc_list +export -f mc_doc_update +export -f mc_doc_delete +export -f mc_doc_search +export -f mc_doc_folder_list