/** * pi-asana - Asana Work Graph tools for pi. * * Adds 14 LLM-callable tools that talk to the Asana REST API * (https://app.asana.com/api/1.0) over plain HTTP+JSON. No MCP server install * is required: this extension issues standard REST calls with a personal * access token (PAT) read from the ASANA_ACCESS_TOKEN environment variable. * * The tool surface mirrors a curated subset of the official Asana MCP server * (https://developers.asana.com/docs/mcp-tools-reference). The 14 tools cover * the read-then-write flows an LLM agent actually needs; noisy duplicates and * Claude/ChatGPT-only confirmation-UI tools are intentionally omitted. * * Tool awareness is injected as a compact system-prompt appendix via * before_agent_start (no skill file, to keep token cost minimal). * * Based on: Asana REST API - https://developers.asana.com/reference * Asana MCP V2 server - https://developers.asana.com/docs/mcp-tools-reference */ import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { getSettingsListTheme } from "@earendil-works/pi-coding-agent"; import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui"; import { searchObjectsTool } from "../lib/tools/search"; import { getMeTool } from "../lib/tools/me"; import { getMyTasksTool } from "../lib/tools/my-tasks"; import { getTaskTool } from "../lib/tools/task"; import { getTaskDescriptionTool } from "../lib/tools/task-description"; import { getTasksTool } from "../lib/tools/tasks"; import { getProjectTool, getProjectsTool } from "../lib/tools/project"; import { statusOverviewTool } from "../lib/tools/status"; import { createTasksTool } from "../lib/tools/create-tasks"; import { updateTasksTool } from "../lib/tools/update-tasks"; import { addCommentTool } from "../lib/tools/comment-add"; import { getCommentTool } from "../lib/tools/comment-get"; import { getTaskCommentsTool } from "../lib/tools/comment-list"; import { CONFIRM_WRITE_FLAG, CONFIRM_WRITE_FLAG_DESCRIPTION, getConfirmWriteEnabled, setConfirmWriteEnabled, } from "../lib/confirm"; // Compact tool guidance appended to the system prompt. Intentionally small: // the tool descriptions themselves carry the detail; this just tells the // agent when to reach for Asana. Mirrors the pi-deepwiki TOOL_GUIDANCE pattern. const TOOL_GUIDANCE = [ "Asana tools (asana_*) are available when ASANA_ACCESS_TOKEN is set in the environment.", 'Use asana_search_objects FIRST when you do not know a GID; pass a workspace from asana_get_me as "workspace".', "Use asana_get_my_tasks as the shortcut for the authenticated user\u2019s task list.", "Use asana_get_tasks with one of project/section/tag/assignee for bulk reads; asana_get_task for full detail on one task.", "asana_get_task truncates the notes/description to 2000 chars; call asana_get_task_description for the full, untruncated body when you need the whole spec.", "Use asana_get_status_overview for cross-project rollups; do not chain a search before it.", "Comments live on the stories endpoint, not the task; use asana_get_task_comments to read recent comment threads on demand (default: last 5). Long comments truncate at 700 chars; the footer prints the story_gid to pass to asana_get_comment for the full body.", "Write tools (asana_create_tasks, asana_update_tasks, asana_add_comment) prompt the user for review before posting to Asana when the `asana-confirm-write` flag is on (default). Call them directly: the extension shows the drafted payload for accept/edit/cancel. The agent does NOT need to ask the user itself.", ].join(" "); function asana(pi: ExtensionAPI): void { // Register the flag for /settings visibility and CLI `--asana-confirm-write` // override ONLY. The gate itself reads file-backed module state // (lib/confirm.ts getConfirmWriteEnabled) because pi extension flags are // in-memory-only with no setFlag API and no persistence path. The default // mirrors the on-disk default so /settings shows a truthful value before // the user has ever toggled. pi.registerFlag(CONFIRM_WRITE_FLAG, { description: CONFIRM_WRITE_FLAG_DESCRIPTION, type: "boolean", default: true, }); pi.registerTool(getMeTool); pi.registerTool(searchObjectsTool); pi.registerTool(getMyTasksTool); pi.registerTool(getTaskTool); pi.registerTool(getTaskDescriptionTool); pi.registerTool(getTasksTool); pi.registerTool(getProjectTool); pi.registerTool(getProjectsTool); pi.registerTool(statusOverviewTool); pi.registerTool(createTasksTool); pi.registerTool(updateTasksTool); pi.registerTool(addCommentTool); pi.registerTool(getTaskCommentsTool); pi.registerTool(getCommentTool); pi.on("before_agent_start", async (event) => { return { systemPrompt: [event.systemPrompt, TOOL_GUIDANCE] .filter(Boolean) .join("\n\n"), }; }); // /asana [args] - prefix the editor with an explicit // instruction so the agent reaches for the right tool deterministically. // // /asana me -> asana_get_me // /asana my -> asana_get_my_tasks // /asana my incomplete -> asana_get_my_tasks (completed=incomplete) // /asana show -> asana_get_task // /asana project -> asana_get_project // /asana search // -> asana_search_objects // /asana status -> asana_get_status_overview // /asana comments [N] -> asana_get_task_comments // /asana comment -> asana_get_comment // /asana create -> hint with asana_create_tasks // /asana config -> settings modal (write review gate) // /asana confirm on|off -> toggle write review gate // // Bare /asana prints a usage reminder. Command handlers cannot directly // dispatch a tool call (pi.sendUserMessage is session-scoped), so we use // the same prefill-the-editor pattern as pi-deepwiki. The user hits Enter // to run; the agent picks the right tool from the prefill. pi.registerCommand("asana", { description: 'Asana tools. Usage: /asana me | /asana my [incomplete|completed] | /asana show | /asana project | /asana search | /asana status [...] | /asana comments [N] | /asana comment | /asana create | /asana config | /asana confirm on|off.', handler: async (args, ctx) => { const trimmed = args.trim(); if (!trimmed) { ctx.ui.notify( 'Usage: /asana me | /asana my | /asana show | /asana project | /asana search | /asana status ... | /asana comments [N] | /asana comment | /asana create | /asana config | /asana confirm on|off', "info", ); return; } const firstSpace = trimmed.indexOf(" "); const verb = (firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase(); const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim(); let prompt: string | null = null; switch (verb) { case "me": prompt = `Call the asana_get_me tool to identify the authenticated Asana user and list their workspaces.`; break; case "my": case "tasks": if (!rest) { prompt = `Call the asana_get_my_tasks tool to list every task currently assigned to the authenticated user.`; } else if (rest === "incomplete" || rest === "completed") { prompt = `Call the asana_get_my_tasks tool with completed="${rest}" to list ${rest} tasks assigned to the authenticated user.`; } else { prompt = `Call the asana_get_my_tasks tool with these arguments as appropriate: ${rest}. If a filter is needed, set completed to "incomplete" by default.`; } break; case "show": if (!/^\d+$/.test(rest)) { ctx.ui.notify( 'Usage: /asana show \nExample: /asana show 1234567890123456', "warning", ); return; } prompt = `Call the asana_get_task tool with gid="${rest}" to retrieve full details for that task.`; break; case "project": if (!/^\d+$/.test(rest)) { ctx.ui.notify( 'Usage: /asana project \nExample: /asana project 1234567890123456', "warning", ); return; } prompt = `Call the asana_get_project tool with gid="${rest}" and include_sections=true to retrieve the project and its section list.`; break; case "search": { // /asana search (workspace is a GID; query can // contain spaces but not equal-signs). const firstSep = rest.indexOf(" "); if (firstSep === -1 || !/^\d+$/.test(rest.slice(0, firstSep))) { ctx.ui.notify( "Usage: /asana search \nExample: /asana search 1234567890123456 'Wicket'", "warning", ); return; } const workspace = rest.slice(0, firstSep); const query = rest.slice(firstSep + 1).trim(); prompt = `Call the asana_search_objects tool with workspace="${workspace}" and query="${query}" (resource_type defaults to task; override to project / user / tag when relevant).`; break; } case "status": { const gids = rest.split(/\s+/).filter((s) => /^\d+$/.test(s)); if (gids.length === 0) { ctx.ui.notify( "Usage: /asana status [...]\nExample: /asana status 1234567890123456 2345678901234567", "warning", ); return; } prompt = `Call the asana_get_status_overview tool with project_gids=${JSON.stringify(gids)} to retrieve the latest status updates for those projects.`; break; } case "comments": { // /asana comments [limit] const parts = rest.split(/\s+/).filter(Boolean); const gid = parts[0] ?? ""; const lim = parts[1]; if (!/^\d+$/.test(gid)) { ctx.ui.notify( "Usage: /asana comments [limit]\nExample: /asana comments 1234567890123456 or /asana comments 1234567890123456 2", "warning", ); return; } const limitNote = lim && /^\d+$/.test(lim) ? ` with limit=${lim}` : ""; prompt = `Call the asana_get_task_comments tool with task_gid="${gid}"${limitNote} to fetch the most-recent comments on that task.`; break; } case "comment": { // /asana comment -> full, untruncated single comment. // Recover the body asana_get_task_comments truncated. if (!/^\d+$/.test(rest)) { ctx.ui.notify( "Usage: /asana comment \nExample: /asana comment 1234567890123456", "warning", ); return; } prompt = `Call the asana_get_comment tool with story_gid="${rest}" to fetch the full, untruncated text of that comment.`; break; } case "config": { // /asana config -> interactive settings modal (TUI) or status (else). // File-backed (lib/confirm.ts): stages flips in the SettingsList, then // persists genuine deltas via setConfirmWriteEnabled — applies live, // no reload needed (the next write-tool call reads module state). await openConfigModal(ctx); return; } case "confirm": { // /asana confirm on|off -> one-shot toggle of the write review gate. // File-backed (lib/confirm.ts setConfirmWriteEnabled): applies live, // no reload needed. const next = rest.toLowerCase(); if (next !== "on" && next !== "off") { ctx.ui.notify('Usage: /asana confirm on|off', "warning"); return; } const value = next === "on"; if (setConfirmWriteEnabled(value)) { ctx.ui.notify(`${CONFIRM_WRITE_FLAG}: ${next}.`, "info"); } else { ctx.ui.notify(`Failed to persist ${CONFIRM_WRITE_FLAG} (disk write failed).`, "error"); } return; } case "create": prompt = `Help me create an Asana task. Read the user's request carefully, resolve any project / assignee names to GIDs via asana_search_objects first, then call asana_create_tasks with the resolved fields. Request: ${rest}`; break; default: prompt = `The user typed "/asana ${trimmed}" with an unknown verb. Show the available verbs (me, my, show, project, search, status, comments, comment, create, config, confirm) and ask what they want.`; break; } if (prompt) ctx.ui.setEditorText(prompt); }, }); } // Settings modal for /asana config. Single flag today, but the SettingsList // path scales if more flags are added later. Non-TUI callers get a status // notify instead (custom components are terminal-only). async function openConfigModal( ctx: ExtensionCommandContext, ): Promise { // Reads the live file-backed value, not pi.getFlag (which is in-memory only). const currentOn = getConfirmWriteEnabled(); if (ctx.mode !== "tui") { ctx.ui.notify( `Asana write review: ${currentOn ? "on" : "off"}.\nToggle: /asana confirm on|off`, "info", ); return; } const items: SettingItem[] = [ { id: CONFIRM_WRITE_FLAG, label: "Confirm before posting writes", description: CONFIRM_WRITE_FLAG_DESCRIPTION, currentValue: currentOn ? "on" : "off", values: ["on", "off"], }, ]; const pending = new Map(); await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild( new Text(theme.fg("accent", theme.bold("Asana extension settings")), 1, 1), ); const settingsList = new SettingsList( items, Math.min(items.length + 2, 15), getSettingsListTheme(), (_id: string, newValue: string) => { pending.set(CONFIRM_WRITE_FLAG, newValue === "on"); }, () => done(undefined), ); container.addChild(settingsList); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); }, }; }); // Drop net-zero flips, persist genuine deltas. File-backed setter applies // live; no reload needed (the next write-tool call reads module state). const target = pending.get(CONFIRM_WRITE_FLAG); if (target === undefined || target === currentOn) return; if (setConfirmWriteEnabled(target)) { ctx.ui.notify(`${CONFIRM_WRITE_FLAG}: ${currentOn} → ${target ? "on" : "off"}.`, "info"); } else { ctx.ui.notify(`Failed to persist ${CONFIRM_WRITE_FLAG} (disk write failed).`, "error"); } } export default asana;