From 49ad1278c6709c22a902a8718561709e1b9787ec Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Tue, 24 Mar 2026 12:49:05 +0100 Subject: [PATCH] feature: token-efficient CLI output for tb-prod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After moving tb-prod to the generic resource layer, CLI token usage regressed from 4x better than MCP to 3x worse. This restores the advantage with three key changes: 1. CSV default output for query/search — 36x smaller than JSON, with schema-driven column selection (displayColumns) and auto-resolved relationship names via sideloaded includes + org cache. 2. Auto-apply default filters — resources with defaultFilters in schema.json (tasks, projects, people, etc.) get sensible defaults merged with user filters. Eliminates the need to call `describe` before querying. 3. Compact output for create/update/delete — one-line confirmation with ID instead of full JSON response. Also fixes: - Operator syntax in flat filters: {"field": {"eq": "value"}} now correctly extracts the operator instead of stringifying the object. - Prime output updated with CSV/auto-filter notes and common queries section with baked-in person_id. - SKILL.md updated to reflect new defaults. - Benchmark script updated for current MCP tool names and /tmp CWD. Benchmark results (my_tasks): Before: CLI 1,323K tokens, 18 calls, 88s (3x worse than MCP) After: CLI 161K tokens, 2 calls, 24s (2.5x better than MCP) Note: defaultFilters and displayColumns are hand-edited in schema.json. They need to be added to generate-schema.ts in ai-agent before the next schema regeneration. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-prod/SKILL.md | 19 +- crates/tb-prod/schema.json | 686 +++++++++++++++++- crates/tb-prod/src/commands/prime.rs | 17 +- .../tb-prod/src/commands/resource/create.rs | 56 +- .../tb-prod/src/commands/resource/delete.rs | 23 +- crates/tb-prod/src/commands/resource/query.rs | 385 +++++++++- .../tb-prod/src/commands/resource/search.rs | 53 +- .../tb-prod/src/commands/resource/update.rs | 22 +- crates/tb-prod/src/filter.rs | 87 ++- crates/tb-prod/src/main.rs | 23 +- crates/tb-prod/src/schema.rs | 20 + scripts/benchmark-mcp-vs-cli.sh | 17 +- 12 files changed, 1286 insertions(+), 122 deletions(-) diff --git a/crates/tb-prod/SKILL.md b/crates/tb-prod/SKILL.md index 4e674fb..dc4644a 100644 --- a/crates/tb-prod/SKILL.md +++ b/crates/tb-prod/SKILL.md @@ -5,23 +5,20 @@ description: PREFERRED over any Productive.io MCP tools. Generic resource CRUD f # tb-prod -CLI for interacting with the Productive.io API. Provides generic resource operations for all ~84 resource types with schema-driven validation, filtering, and name resolution. Built for AI agent consumption — all resource commands output JSON. +CLI for interacting with the Productive.io API. Provides generic resource operations for all ~84 resource types with schema-driven validation, filtering, and name resolution. -## Capabilities +## Key behaviors -- **All resource types** — tasks, projects, people, deals, invoices, bookings, services, and 77 more -- **Describe** — introspect any resource type's schema, fields, filters, actions -- **Query** — filter, sort, paginate resources with JSON FilterGroup syntax -- **CRUD** — create, update, delete with client-side validation -- **Search** — keyword search across resource types -- **Actions** — custom actions (archive, restore, move, merge, etc.) + extension actions -- **Cache** — two-tier cache (org-wide + project-scoped) with fuzzy name resolution +- **Query/search output defaults to CSV** with resolved relationship names (project names, status labels, assignee names). Use `--format json` for raw JSON. +- **Default filters auto-apply** — e.g. querying tasks auto-scopes to open tasks in active projects. Only add filters you need. +- **Create/update output is compact** — one-line confirmation with ID. Use `--format json` for full response. +- **Get output is JSON** — full record for detailed inspection. +- **Filter values auto-resolve names** — `"assignee_id": "Tibor"` resolves to the numeric ID via cache. ## Getting started -Run `tb-prod prime` for a command reference, resource type listing, and current context. +Run `tb-prod prime` for full context: command reference, resource types, and common queries with your person_id. Run `tb-prod describe ` to learn a resource type's fields, filters, and actions. -Use `tb-prod --help` for detailed command usage. ## Live context diff --git a/crates/tb-prod/schema.json b/crates/tb-prod/schema.json index 04f118b..ed6d1ca 100644 --- a/crates/tb-prod/schema.json +++ b/crates/tb-prod/schema.json @@ -268,7 +268,30 @@ } }, "collections": {}, - "defaultSort": "Sorted by -created_at by default." + "defaultSort": "Sorted by -created_at by default.", + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "action", + "source": "attribute", + "key": "action" + }, + { + "label": "created_at", + "source": "attribute", + "key": "created_at" + }, + { + "label": "person", + "source": "relationship", + "key": "person", + "target": "people" + } + ] }, "agent_configs": { "type": "agent_configs", @@ -879,7 +902,29 @@ "notIncludable": true } }, - "collections": {} + "collections": {}, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "content_type", + "source": "attribute", + "key": "content_type" + }, + { + "label": "created_at", + "source": "attribute", + "key": "created_at" + } + ] }, "automations": { "type": "automations", @@ -1418,7 +1463,52 @@ "create": true, "update": true, "delete": true - } + }, + "defaultFilters": { + "canceled": { + "eq": "false" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "started_on", + "source": "attribute", + "key": "started_on" + }, + { + "label": "ended_on", + "source": "attribute", + "key": "ended_on" + }, + { + "label": "time", + "source": "attribute", + "key": "time" + }, + { + "label": "person", + "source": "relationship", + "key": "person", + "target": "people" + }, + { + "label": "project", + "source": "relationship", + "key": "project", + "target": "projects" + }, + { + "label": "service", + "source": "relationship", + "key": "service", + "target": "services" + } + ] }, "comments": { "type": "comments", @@ -1536,7 +1626,30 @@ } }, "collections": {}, - "searchFilterParam": "full_query" + "searchFilterParam": "full_query", + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "body_preview", + "source": "attribute", + "key": "body_preview" + }, + { + "label": "created_at", + "source": "attribute", + "key": "created_at" + }, + { + "label": "person", + "source": "relationship", + "key": "person", + "target": "people" + } + ] }, "companies": { "type": "companies", @@ -1764,7 +1877,39 @@ "syncFilter": { "status": "1" } - } + }, + "defaultFilters": { + "status": { + "eq": "1" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "status", + "source": "attribute", + "key": "status" + }, + { + "label": "email", + "source": "attribute", + "key": "email" + }, + { + "label": "phone", + "source": "attribute", + "key": "phone" + } + ] }, "contact_entries": { "type": "contact_entries", @@ -3044,7 +3189,55 @@ "bulkActions": { "update": true, "delete": true - } + }, + "defaultFilters": { + "budget": { + "eq": "false" + }, + "stage_status_id": { + "eq": "1" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "budget", + "source": "attribute", + "key": "budget" + }, + { + "label": "date", + "source": "attribute", + "key": "date" + }, + { + "label": "company", + "source": "relationship", + "key": "company", + "target": "companies" + }, + { + "label": "deal_status", + "source": "relationship", + "key": "deal_status", + "target": "deal_statuses" + }, + { + "label": "responsible", + "source": "relationship", + "key": "responsible", + "target": "people" + } + ] }, "deleted_items": { "type": "deleted_items", @@ -3364,7 +3557,12 @@ "document template" ], "queryHints": "Users typically care about active document types, unless they specify otherwise. The default filter scope for document types should be: { status: {eq: '1'} }", - "defaultSort": "Sorted by name (ascending) by default." + "defaultSort": "Sorted by name (ascending) by default.", + "defaultFilters": { + "status": { + "eq": "1" + } + } }, "einvoice_configurations": { "type": "einvoice_configurations", @@ -3808,6 +4006,11 @@ "id", "name" ] + }, + "defaultFilters": { + "status": { + "eq": "1" + } } }, "expenses": { @@ -4259,7 +4462,46 @@ } }, "collections": {}, - "searchFilterParam": "query" + "searchFilterParam": "query", + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "date", + "source": "attribute", + "key": "date" + }, + { + "label": "amount", + "source": "attribute", + "key": "amount" + }, + { + "label": "approved", + "source": "attribute", + "key": "approved" + }, + { + "label": "person", + "source": "relationship", + "key": "person", + "target": "people" + }, + { + "label": "service", + "source": "relationship", + "key": "service", + "target": "services" + } + ] }, "filters": { "type": "filters", @@ -4395,7 +4637,24 @@ "project_id", "status" ] - } + }, + "defaultFilters": { + "status": { + "eq": "1" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + } + ] }, "holiday_calendars": { "type": "holiday_calendars", @@ -4916,7 +5175,45 @@ } }, "collections": {}, - "searchFilterParam": "query" + "searchFilterParam": "query", + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "number", + "source": "attribute", + "key": "number" + }, + { + "label": "date", + "source": "attribute", + "key": "date" + }, + { + "label": "due_date", + "source": "attribute", + "key": "due_date" + }, + { + "label": "total", + "source": "attribute", + "key": "total" + }, + { + "label": "status", + "source": "attribute", + "key": "status" + }, + { + "label": "company", + "source": "relationship", + "key": "company", + "target": "companies" + } + ] }, "kpd_codes": { "type": "kpd_codes", @@ -5510,7 +5807,35 @@ } }, "collections": {}, - "queryHints": "Users typically care about active (non-dismissed) notifications they are subscribed to (important=true), unless they specify otherwise. The default filter scope for notifications should be: { dismissed: {eq: 'false'}, important: {eq: 'true'} }." + "queryHints": "Users typically care about active (non-dismissed) notifications they are subscribed to (important=true), unless they specify otherwise. The default filter scope for notifications should be: { dismissed: {eq: 'false'}, important: {eq: 'true'} }.", + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "subject", + "source": "attribute", + "key": "subject" + }, + { + "label": "read", + "source": "attribute", + "key": "read" + }, + { + "label": "created_at", + "source": "attribute", + "key": "created_at" + }, + { + "label": "person", + "source": "relationship", + "key": "person", + "target": "people" + } + ] }, "organization_memberships": { "type": "organization_memberships", @@ -5938,6 +6263,34 @@ "collections": {}, "searchQuickResultType": [ "page" + ], + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "title", + "source": "attribute", + "key": "title" + }, + { + "label": "published", + "source": "attribute", + "key": "published" + }, + { + "label": "last_activity_at", + "source": "attribute", + "key": "last_activity_at" + }, + { + "label": "project", + "source": "relationship", + "key": "project", + "target": "projects" + } ] }, "payments": { @@ -6351,7 +6704,45 @@ "status": "1", "person_type": "1" } - } + }, + "defaultFilters": { + "status": { + "eq": "1" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "first_name", + "source": "attribute", + "key": "first_name" + }, + { + "label": "last_name", + "source": "attribute", + "key": "last_name" + }, + { + "label": "email", + "source": "attribute", + "key": "email" + }, + { + "label": "title", + "source": "attribute", + "key": "title" + }, + { + "label": "company", + "source": "relationship", + "key": "company", + "target": "companies" + } + ] }, "pipelines": { "type": "pipelines", @@ -6793,7 +7184,47 @@ "syncFilter": { "status": "1" } - } + }, + "defaultFilters": { + "status": { + "eq": "1" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "status", + "source": "attribute", + "key": "status" + }, + { + "label": "company", + "source": "relationship", + "key": "company", + "target": "companies" + }, + { + "label": "workflow", + "source": "relationship", + "key": "workflow", + "target": "workflows" + }, + { + "label": "project_manager", + "source": "relationship", + "key": "project_manager", + "target": "people" + } + ] }, "proposal_sections": { "type": "proposal_sections", @@ -8747,6 +9178,11 @@ "syncFilter": { "status": "1" } + }, + "defaultFilters": { + "status": { + "eq": "1" + } } }, "services": { @@ -9033,7 +9469,41 @@ "create": true, "update": true, "delete": true - } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "billing_type", + "source": "attribute", + "key": "billing_type" + }, + { + "label": "unit", + "source": "attribute", + "key": "unit" + }, + { + "label": "deal", + "source": "relationship", + "key": "deal", + "target": "deals" + }, + { + "label": "service_type", + "source": "relationship", + "key": "service_type", + "target": "service_types" + } + ] }, "slack_channel": { "type": "slack_channel", @@ -9546,7 +10016,30 @@ "project_id", "status" ] - } + }, + "defaultFilters": { + "status": { + "eq": "1" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "project", + "source": "relationship", + "key": "project", + "target": "projects" + } + ] }, "tasks": { "type": "tasks", @@ -9912,7 +10405,61 @@ "create": true, "update": true, "delete": true - } + }, + "defaultFilters": { + "project.status": { + "eq": "1" + }, + "task_list_status": { + "eq": "1" + }, + "board_status": { + "eq": "1" + }, + "workflow_status_category_id": { + "not_eq": "3" + } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "title", + "source": "attribute", + "key": "title" + }, + { + "label": "taskNumber", + "source": "attribute", + "key": "task_number" + }, + { + "label": "dueDate", + "source": "attribute", + "key": "due_date" + }, + { + "label": "project", + "source": "relationship", + "key": "project", + "target": "projects" + }, + { + "label": "workflow_status", + "source": "relationship", + "key": "workflow_status", + "target": "workflow_statuses" + }, + { + "label": "assignee", + "source": "relationship", + "key": "assignee", + "target": "people" + } + ] }, "tax_rates": { "type": "tax_rates", @@ -10303,7 +10850,47 @@ "create": true, "update": true, "delete": true - } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "date", + "source": "attribute", + "key": "date" + }, + { + "label": "time", + "source": "attribute", + "key": "time" + }, + { + "label": "note", + "source": "attribute", + "key": "note" + }, + { + "label": "person", + "source": "relationship", + "key": "person", + "target": "people" + }, + { + "label": "service", + "source": "relationship", + "key": "service", + "target": "services" + }, + { + "label": "task", + "source": "relationship", + "key": "task", + "target": "tasks" + } + ] }, "timers": { "type": "timers", @@ -10507,7 +11094,30 @@ "create": true, "update": true, "delete": true - } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "title", + "source": "attribute", + "key": "title" + }, + { + "label": "completed", + "source": "attribute", + "key": "completed" + }, + { + "label": "assignee", + "source": "relationship", + "key": "assignee", + "target": "people" + } + ] }, "users": { "type": "users", @@ -10620,7 +11230,29 @@ "color_id", "category_id" ] - } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + }, + { + "label": "category_id", + "source": "attribute", + "key": "category_id" + }, + { + "label": "color_id", + "source": "attribute", + "key": "color_id" + } + ] }, "workflows": { "type": "workflows", @@ -10673,7 +11305,19 @@ "id", "name" ] - } + }, + "displayColumns": [ + { + "label": "ID", + "source": "id", + "key": "id" + }, + { + "label": "name", + "source": "attribute", + "key": "name" + } + ] } }, "enums": { @@ -12007,4 +12651,4 @@ } } } -} \ No newline at end of file +} diff --git a/crates/tb-prod/src/commands/prime.rs b/crates/tb-prod/src/commands/prime.rs index cad0a2e..dec73bc 100644 --- a/crates/tb-prod/src/commands/prime.rs +++ b/crates/tb-prod/src/commands/prime.rs @@ -63,8 +63,9 @@ pub async fn run(client: &ProductiveClient, config: &Config) -> Result<()> { // --- Notes --- println!("## Notes\n"); - println!("- Output is JSON for all resource commands (except `describe` and `prime`)"); - println!("- `--filter` and `--data` accept JSON via flag or piped stdin"); + println!("- Default output is CSV with resolved relationship names (use `--format json` for raw JSON)"); + println!("- Default filters auto-apply (e.g. tasks auto-scoped to open tasks in active projects). Only add filters you need."); + println!("- `--filter` accepts JSON: flat `{{\"field\": \"value\"}}` or operator `{{\"field\": {{\"not_eq\": \"value\"}}}}`"); println!( "- Filter values for cacheable types (projects, people, etc.) auto-resolve names to IDs" ); @@ -72,6 +73,18 @@ pub async fn run(client: &ProductiveClient, config: &Config) -> Result<()> { println!("- `tb-prod prime project ` for deep project context"); println!("- `tb-prod cache sync` to refresh cached data"); + // --- Common queries --- + println!("\n## Common Queries\n"); + println!("```"); + println!( + "tb-prod query tasks --filter '{{\"assignee_id\": \"{}\"}}'", + person_id + ); + println!("tb-prod query projects"); + println!("tb-prod query time_entries --filter '{{\"person_id\": \"{}\"}}'", person_id); + println!("tb-prod query bookings --filter '{{\"person_id\": \"{}\"}}'", person_id); + println!("```"); + Ok(()) } diff --git a/crates/tb-prod/src/commands/resource/create.rs b/crates/tb-prod/src/commands/resource/create.rs index 74d1daf..c408909 100644 --- a/crates/tb-prod/src/commands/resource/create.rs +++ b/crates/tb-prod/src/commands/resource/create.rs @@ -2,18 +2,24 @@ use serde_json::Value; use crate::api::ProductiveClient; use crate::body; +use crate::commands::resource::query; use crate::json_error; use crate::schema::ResourceDef; use crate::validate; -pub async fn run(client: &ProductiveClient, resource: &ResourceDef, data: &Value) { +pub async fn run( + client: &ProductiveClient, + resource: &ResourceDef, + data: &Value, + format: &str, +) { let schema = crate::schema::schema(); // Bulk or single? if let Some(items) = data.as_array() { - run_bulk(client, resource, items).await; + run_bulk(client, resource, items, format).await; } else { - run_single(client, resource, data, schema).await; + run_single(client, resource, data, schema, format).await; } } @@ -22,6 +28,7 @@ async fn run_single( resource: &ResourceDef, data: &Value, schema: &crate::schema::Schema, + format: &str, ) { if !resource.supports_action("create") { json_error::exit_with_error( @@ -55,14 +62,31 @@ async fn run_single( let path = resource.api_path(); match client.create(&path, &payload).await { Ok(resp) => { - let output = serde_json::json!({"data": resp.data}); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + if format == "json" { + let output = serde_json::json!({"data": resp.data}); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + let name = query::extract_display_name(&resp.data); + if name.is_empty() { + println!("Created {} {}", resource.item_name, resp.data.id); + } else { + println!( + "Created {} {} — {}", + resource.item_name, resp.data.id, name + ); + } + } } Err(e) => json_error::exit_with_tb_error(&e), } } -async fn run_bulk(client: &ProductiveClient, resource: &ResourceDef, items: &[Value]) { +async fn run_bulk( + client: &ProductiveClient, + resource: &ResourceDef, + items: &[Value], + format: &str, +) { if !resource.supports_bulk("create") { json_error::exit_with_error( "bulk_not_supported", @@ -98,8 +122,24 @@ async fn run_bulk(client: &ProductiveClient, resource: &ResourceDef, items: &[Va let path = resource.api_path(); match client.bulk_create(&path, &payload).await { Ok(resp) => { - let output = serde_json::json!({"data": resp.data}); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + if format == "json" { + let output = serde_json::json!({"data": resp.data}); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + println!( + "Created {} {}", + resp.data.len(), + resource.collection_name + ); + for r in &resp.data { + let name = query::extract_display_name(r); + if name.is_empty() { + println!(" {} {}", resource.item_name, r.id); + } else { + println!(" {} {} — {}", resource.item_name, r.id, name); + } + } + } } Err(e) => json_error::exit_with_tb_error(&e), } diff --git a/crates/tb-prod/src/commands/resource/delete.rs b/crates/tb-prod/src/commands/resource/delete.rs index fca6c45..dff1d61 100644 --- a/crates/tb-prod/src/commands/resource/delete.rs +++ b/crates/tb-prod/src/commands/resource/delete.rs @@ -11,30 +11,17 @@ pub async fn run(client: &ProductiveClient, resource: &ResourceDef, id: &str, co } if !confirm { - // Dry run — show what would be deleted - let output = serde_json::json!({ - "dryRun": true, - "action": "delete", - "type": resource.type_name, - "id": id, - "message": format!( - "Would delete {} {}. Use --confirm to execute.", - resource.item_name, id - ), - }); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + println!( + "Would delete {} {}. Use --confirm to execute.", + resource.item_name, id + ); return; } let path = format!("{}/{}", resource.api_path(), id); match client.delete(&path).await { Ok(()) => { - let output = serde_json::json!({ - "deleted": true, - "type": resource.type_name, - "id": id, - }); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + println!("Deleted {} {}", resource.item_name, id); } Err(e) => json_error::exit_with_tb_error(&e), } diff --git a/crates/tb-prod/src/commands/resource/query.rs b/crates/tb-prod/src/commands/resource/query.rs index 07064d3..aa7e2d7 100644 --- a/crates/tb-prod/src/commands/resource/query.rs +++ b/crates/tb-prod/src/commands/resource/query.rs @@ -1,8 +1,10 @@ -use crate::api::{ProductiveClient, Query}; +use std::collections::HashMap; + +use crate::api::{ProductiveClient, Query, Resource}; use crate::filter::{self, FilterInput}; use crate::generic_cache::GenericCache; use crate::json_error; -use crate::schema::ResourceDef; +use crate::schema::{DisplayColumn, ResourceDef, Schema, TypeCategory}; pub async fn run( client: &ProductiveClient, @@ -11,6 +13,7 @@ pub async fn run( sort: Option<&str>, page: Option, include: Option<&str>, + format: &str, ) { if !resource.supports_action("index") { json_error::exit_with_error( @@ -20,19 +23,25 @@ pub async fn run( } let schema = crate::schema::schema(); + let json_mode = format == "json"; // Build query let mut query = Query::new(); - // Parse and validate filter - if let Some(filter_str) = filter_json { - let input: FilterInput = match serde_json::from_str(filter_str) { + // Parse user filter (if any) + let user_input: Option = filter_json.map(|filter_str| { + match serde_json::from_str(filter_str) { Ok(f) => f, Err(e) => { json_error::exit_with_error("invalid_json", &format!("Invalid filter JSON: {e}")); } - }; + } + }); + + // Merge with default filters: defaults apply unless user explicitly overrides the same field + let merged_input = merge_with_defaults(user_input, resource); + if let Some(input) = merged_input { let mut group = filter::normalize_filter(input); // Validate @@ -68,9 +77,16 @@ pub async fn run( query = query.sort(default); } - // Include - if let Some(includes) = include { - query = query.include(includes); + // Include: merge user includes with auto-includes for CSV mode + if !json_mode { + let auto = auto_includes_from_columns(resource); + let user = include.unwrap_or(""); + let merged = merge_includes(&auto, user); + if !merged.is_empty() { + query = query.include(&merged); + } + } else if let Some(inc) = include { + query = query.include(inc); } // Pagination @@ -91,18 +107,347 @@ pub async fn run( .and_then(|v| v.as_u64()) .unwrap_or(1); - let output = serde_json::json!({ - "data": resp.data, - "included": resp.included, - "meta": { - "totalCount": total_count, - "totalPages": total_pages, - "currentPage": page_num, - "hasNextPage": (page_num as u64) < total_pages, - } - }); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + if json_mode { + let output = serde_json::json!({ + "data": resp.data, + "included": resp.included, + "meta": { + "totalCount": total_count, + "totalPages": total_pages, + "currentPage": page_num, + "hasNextPage": (page_num as u64) < total_pages, + } + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + print_csv( + &resp.data, + &resp.included, + resource, + schema, + client.org_id(), + total_count, + total_pages, + page_num, + ); + } } Err(e) => json_error::exit_with_tb_error(&e), } } + +// --- Default filter merging --- + +fn merge_with_defaults( + user_input: Option, + resource: &ResourceDef, +) -> Option { + let defaults = match &resource.default_filters { + Some(d) if !d.is_empty() => d, + _ => return user_input, + }; + + match user_input { + None => { + let map: serde_json::Map = defaults.clone(); + Some(FilterInput::Flat(map)) + } + Some(FilterInput::Flat(mut user_map)) => { + for (key, value) in defaults { + if !user_map.contains_key(key) { + user_map.insert(key.clone(), value.clone()); + } + } + Some(FilterInput::Flat(user_map)) + } + Some(group @ FilterInput::Group(_)) => Some(group), + } +} + +// --- Include merging --- + +/// Public accessor for search.rs +pub fn auto_includes_from_columns_pub(resource: &ResourceDef) -> Vec { + auto_includes_from_columns(resource) +} + +/// Derive auto-includes from displayColumns config. +fn auto_includes_from_columns(resource: &ResourceDef) -> Vec { + match &resource.display_columns { + Some(cols) => cols + .iter() + .filter(|c| c.source == "relationship") + .filter_map(|c| { + // Check that the relationship is includable + let field = resource.fields.values().find(|f| { + f.relationship.as_deref() == Some(&c.key) && !f.not_includable + }); + field.map(|_| c.key.clone()) + }) + .collect(), + None => fallback_auto_includes(resource), + } +} + +/// Fallback auto-includes for resources without displayColumns. +fn fallback_auto_includes(resource: &ResourceDef) -> Vec { + let priority = ["project", "workflow_status", "assignee", "company", "deal", "person", "service"]; + priority + .iter() + .filter(|&&rel| { + resource.fields.values().any(|f| { + f.type_category == TypeCategory::Resource + && f.relationship.as_deref() == Some(rel) + && !f.not_includable + && !f.array + }) + }) + .map(|s| s.to_string()) + .collect() +} + +/// Merge auto-includes with user-specified includes (additive). +fn merge_includes(auto: &[String], user: &str) -> String { + let mut all: Vec = auto.to_vec(); + for inc in user.split(',').map(|s| s.trim()) { + if !inc.is_empty() && !all.iter().any(|a| a == inc) { + all.push(inc.to_string()); + } + } + all.join(",") +} + +// --- Name resolution --- + +/// Build a lookup from (type, id) → display name using included resources and cache. +pub fn build_name_lookup( + included: &[Resource], + schema: &Schema, + org_id: &str, +) -> HashMap<(String, String), String> { + let mut lookup: HashMap<(String, String), String> = HashMap::new(); + + // From included (sideloaded) resources + for inc in included { + let name = extract_display_name(inc); + if !name.is_empty() { + lookup.insert((inc.resource_type.clone(), inc.id.clone()), name); + } + } + + // Supplement with org cache + if let Ok(cache) = GenericCache::new(org_id) { + for type_name in &["projects", "people", "companies", "service_types"] { + if let Ok(records) = cache.read_org_cache(type_name) { + let display_field = schema + .resources + .get(*type_name) + .and_then(|t| t.cache.as_ref()) + .map(|c| c.display_field.as_str()) + .unwrap_or("name"); + + for r in records { + let key = (type_name.to_string(), r.id.clone()); + if lookup.contains_key(&key) { + continue; + } + let name = if display_field == "name" { + if let (Some(first), Some(last)) = + (r.fields.get("first_name"), r.fields.get("last_name")) + { + format!("{} {}", first, last).trim().to_string() + } else { + r.fields.get("name").cloned().unwrap_or_default() + } + } else { + r.fields.get(display_field).cloned().unwrap_or_default() + }; + if !name.is_empty() { + lookup.insert(key, name); + } + } + } + } + } + + lookup +} + +/// Extract a display name from a Resource (from included data). +pub fn extract_display_name(resource: &Resource) -> String { + for key in &["name", "title"] { + let val = resource.attr_str(key); + if !val.is_empty() { + return val.to_string(); + } + } + let first = resource.attr_str("first_name"); + let last = resource.attr_str("last_name"); + if !first.is_empty() || !last.is_empty() { + return format!("{} {}", first, last).trim().to_string(); + } + String::new() +} + +// --- Column building --- + +type ColumnExtractor = Box) -> String>; + +/// Build columns from displayColumns config, or fall back to heuristic. +fn build_columns(resource: &ResourceDef) -> Vec<(String, ColumnExtractor)> { + match &resource.display_columns { + Some(cols) => cols.iter().map(|col| build_column_from_config(col)).collect(), + None => build_columns_fallback(resource), + } +} + +fn build_column_from_config(col: &DisplayColumn) -> (String, ColumnExtractor) { + let label = col.label.clone(); + match col.source.as_str() { + "id" => (label, Box::new(|r: &Resource, _| r.id.clone())), + "attribute" => { + let key = col.key.clone(); + ( + label, + Box::new(move |r: &Resource, _| { + r.attributes + .get(&key) + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Bool(b) => b.to_string(), + other => other.to_string(), + }) + .unwrap_or_default() + }), + ) + } + "relationship" => { + let rel_name = col.key.clone(); + let target_type = col.target.clone().unwrap_or_default(); + ( + label, + Box::new(move |r: &Resource, lookup: &HashMap<(String, String), String>| { + if let Some(id) = r.relationship_id(&rel_name) { + lookup + .get(&(target_type.clone(), id.to_string())) + .cloned() + .unwrap_or_else(|| format!("#{}", id)) + } else { + String::new() + } + }), + ) + } + _ => (label, Box::new(|_, _| String::new())), + } +} + +/// Fallback column builder for resources without displayColumns. +fn build_columns_fallback(resource: &ResourceDef) -> Vec<(String, ColumnExtractor)> { + let mut cols: Vec<(String, ColumnExtractor)> = Vec::new(); + + // ID + cols.push(("ID".to_string(), Box::new(|r: &Resource, _| r.id.clone()))); + + // All non-readonly, non-array, serializable attribute fields (up to 8 total) + let mut fields: Vec<&crate::schema::FieldDef> = resource + .fields + .values() + .filter(|f| { + f.type_category == TypeCategory::Primitive + && f.serialize + && !f.array + && f.attribute.is_some() + }) + .collect(); + // Put title/name first + fields.sort_by_key(|f| { + if f.key == "title" || f.key == "name" { + 0 + } else if f.filter.is_some() { + 1 + } else { + 2 + } + }); + + for field in fields.iter().take(7) { + let attr_key = field.attribute.as_ref().unwrap().clone(); + let label = field.key.clone(); + cols.push(( + label, + Box::new(move |r: &Resource, _| { + r.attributes + .get(&attr_key) + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Bool(b) => b.to_string(), + other => other.to_string(), + }) + .unwrap_or_default() + }), + )); + } + + cols +} + +// --- CSV output --- + +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +pub fn print_csv( + data: &[Resource], + included: &[Resource], + resource: &ResourceDef, + schema: &Schema, + org_id: &str, + total_count: u64, + total_pages: u64, + current_page: usize, +) { + if data.is_empty() { + println!("No {} found.", resource.type_name); + println!("# page {}/{}", current_page, total_pages); + return; + } + + let lookup = build_name_lookup(included, schema, org_id); + let columns = build_columns(resource); + + // Header + let header: String = columns + .iter() + .map(|(h, _)| csv_escape(h)) + .collect::>() + .join(","); + println!("{}", header); + + // Rows + for record in data { + let row: String = columns + .iter() + .map(|(_, extractor)| csv_escape(&extractor(record, &lookup))) + .collect::>() + .join(","); + println!("{}", row); + } + + // Footer + println!( + "# {} {} (page {}/{}, {} total)", + data.len(), + resource.type_name, + current_page, + total_pages, + total_count + ); +} diff --git a/crates/tb-prod/src/commands/resource/search.rs b/crates/tb-prod/src/commands/resource/search.rs index c2b2f43..881f10a 100644 --- a/crates/tb-prod/src/commands/resource/search.rs +++ b/crates/tb-prod/src/commands/resource/search.rs @@ -1,8 +1,14 @@ use crate::api::{ProductiveClient, Query}; +use crate::commands::resource::query; use crate::json_error; use crate::schema::ResourceDef; -pub async fn run(client: &ProductiveClient, resource: &ResourceDef, query_text: &str) { +pub async fn run( + client: &ProductiveClient, + resource: &ResourceDef, + query_text: &str, + format: &str, +) { let search_param = match &resource.search_filter_param { Some(p) => p.as_str(), None => { @@ -13,11 +19,23 @@ pub async fn run(client: &ProductiveClient, resource: &ResourceDef, query_text: } }; - let query = Query::new().filter(search_param, query_text); + let schema = crate::schema::schema(); + let json_mode = format == "json"; + + let mut api_query = Query::new().filter(search_param, query_text); + + // Auto-include for CSV mode + if !json_mode { + let auto = query::auto_includes_from_columns_pub(resource); + if !auto.is_empty() { + api_query = api_query.include(&auto.join(",")); + } + } + let path = resource.api_path(); let page_size = 20; - match client.get_page(&path, &query, 1, page_size).await { + match client.get_page(&path, &api_query, 1, page_size).await { Ok(resp) => { let total_count = resp .meta @@ -25,14 +43,27 @@ pub async fn run(client: &ProductiveClient, resource: &ResourceDef, query_text: .and_then(|v| v.as_u64()) .unwrap_or(0); - let output = serde_json::json!({ - "data": resp.data, - "meta": { - "totalCount": total_count, - "query": query_text, - } - }); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + if json_mode { + let output = serde_json::json!({ + "data": resp.data, + "meta": { + "totalCount": total_count, + "query": query_text, + } + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + query::print_csv( + &resp.data, + &resp.included, + resource, + schema, + client.org_id(), + total_count, + 1, + 1, + ); + } } Err(e) => json_error::exit_with_tb_error(&e), } diff --git a/crates/tb-prod/src/commands/resource/update.rs b/crates/tb-prod/src/commands/resource/update.rs index 1756a70..e90bcd8 100644 --- a/crates/tb-prod/src/commands/resource/update.rs +++ b/crates/tb-prod/src/commands/resource/update.rs @@ -2,11 +2,18 @@ use serde_json::Value; use crate::api::ProductiveClient; use crate::body; +use crate::commands::resource::query; use crate::json_error; use crate::schema::ResourceDef; use crate::validate; -pub async fn run(client: &ProductiveClient, resource: &ResourceDef, id: &str, data: &Value) { +pub async fn run( + client: &ProductiveClient, + resource: &ResourceDef, + id: &str, + data: &Value, + format: &str, +) { if !resource.supports_action("update") { json_error::exit_with_error( "operation_not_supported", @@ -41,8 +48,17 @@ pub async fn run(client: &ProductiveClient, resource: &ResourceDef, id: &str, da let path = format!("{}/{}", resource.api_path(), id); match client.update(&path, &payload).await { Ok(resp) => { - let output = serde_json::json!({"data": resp.data}); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + if format == "json" { + let output = serde_json::json!({"data": resp.data}); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + let name = query::extract_display_name(&resp.data); + if name.is_empty() { + println!("Updated {} {}", resource.item_name, id); + } else { + println!("Updated {} {} — {}", resource.item_name, id, name); + } + } } Err(e) => json_error::exit_with_tb_error(&e), } diff --git a/crates/tb-prod/src/filter.rs b/crates/tb-prod/src/filter.rs index c5f600f..a126120 100644 --- a/crates/tb-prod/src/filter.rs +++ b/crates/tb-prod/src/filter.rs @@ -50,6 +50,24 @@ impl FilterValue { } } +// --- Helpers --- + +fn value_to_filter_value(value: Value) -> FilterValue { + match value { + Value::String(s) => FilterValue::Single(s), + Value::Number(n) => FilterValue::Single(n.to_string()), + Value::Array(arr) => FilterValue::Array( + arr.into_iter() + .map(|v| match v { + Value::String(s) => s, + other => other.to_string().trim_matches('"').to_string(), + }) + .collect(), + ), + other => FilterValue::Single(other.to_string().trim_matches('"').to_string()), + } +} + // --- Normalization --- /// Normalize a FilterInput into a FilterGroup. @@ -61,26 +79,26 @@ pub fn normalize_filter(input: FilterInput) -> FilterGroup { let conditions = map .into_iter() .map(|(field, value)| { - let val = match value { - Value::String(s) => FilterValue::Single(s), - Value::Number(n) => FilterValue::Single(n.to_string()), - Value::Array(arr) => FilterValue::Array( - arr.into_iter() - .map(|v| match v { - Value::String(s) => s, - other => other.to_string().trim_matches('"').to_string(), - }) - .collect(), - ), - other => { - FilterValue::Single(other.to_string().trim_matches('"').to_string()) + match value { + // Operator object: {"eq": "175235"} or {"not_eq": "3"} + Value::Object(obj) if !obj.is_empty() => { + let (op, val) = obj.into_iter().next().unwrap(); + let filter_val = value_to_filter_value(val); + FilterEntry::Condition(FilterCondition { + field, + operator: op, + value: filter_val, + }) + } + _ => { + let val = value_to_filter_value(value); + FilterEntry::Condition(FilterCondition { + field, + operator: "eq".to_string(), + value: val, + }) } - }; - FilterEntry::Condition(FilterCondition { - field, - operator: "eq".to_string(), - value: val, - }) + } }) .collect(); FilterGroup { @@ -314,6 +332,37 @@ mod tests { assert_eq!(group.conditions.len(), 2); } + #[test] + fn normalize_flat_filter_with_operator_object() { + let input: FilterInput = serde_json::from_str( + r#"{"assignee_id": {"eq": "175235"}, "workflow_status_category_id": {"not_eq": "3"}}"#, + ) + .unwrap(); + let group = normalize_filter(input); + assert_eq!(group.op, "and"); + assert_eq!(group.conditions.len(), 2); + + // Verify operators are extracted correctly + let mut found_eq = false; + let mut found_not_eq = false; + for entry in &group.conditions { + if let FilterEntry::Condition(c) = entry { + if c.field == "assignee_id" { + assert_eq!(c.operator, "eq"); + assert_eq!(c.value.as_strings(), vec!["175235"]); + found_eq = true; + } + if c.field == "workflow_status_category_id" { + assert_eq!(c.operator, "not_eq"); + assert_eq!(c.value.as_strings(), vec!["3"]); + found_not_eq = true; + } + } + } + assert!(found_eq, "missing assignee_id condition"); + assert!(found_not_eq, "missing workflow_status_category_id condition"); + } + #[test] fn normalize_group_filter() { let input: FilterInput = serde_json::from_str( diff --git a/crates/tb-prod/src/main.rs b/crates/tb-prod/src/main.rs index 70b18c4..6ec4ec2 100644 --- a/crates/tb-prod/src/main.rs +++ b/crates/tb-prod/src/main.rs @@ -48,6 +48,9 @@ enum Commands { /// Include relationships (comma-separated) #[arg(long)] include: Option, + /// Output format: csv (default), table, or json + #[arg(long, default_value = "csv")] + format: String, }, /// Get a single resource by ID Get { @@ -66,6 +69,9 @@ enum Commands { /// JSON data (object for single, array for bulk) #[arg(long)] data: Option, + /// Output format: compact (default) or json + #[arg(long, default_value = "compact")] + format: String, }, /// Update a resource by ID Update { @@ -76,6 +82,9 @@ enum Commands { /// JSON data (partial fields to update) #[arg(long)] data: Option, + /// Output format: compact (default) or json + #[arg(long, default_value = "compact")] + format: String, }, /// Delete a resource by ID Delete { @@ -94,6 +103,9 @@ enum Commands { /// Search query text #[arg(long)] query: String, + /// Output format: csv (default) or json + #[arg(long, default_value = "csv")] + format: String, }, /// Execute a custom action on a resource Action { @@ -240,6 +252,7 @@ async fn run() -> tb_prod::error::Result<()> { sort, page, include, + format, } => { let resource = resolve_resource_or_exit(&resource_type); let filter_value = match &filter { @@ -253,6 +266,7 @@ async fn run() -> tb_prod::error::Result<()> { sort.as_deref(), Some(page), include.as_deref(), + &format, ) .await; } @@ -267,19 +281,21 @@ async fn run() -> tb_prod::error::Result<()> { Commands::Create { resource_type, data, + format, } => { let resource = resolve_resource_or_exit(&resource_type); let json_data = input::require_json_input(data.as_deref(), "create"); - commands::resource::create::run(&client, resource, &json_data).await; + commands::resource::create::run(&client, resource, &json_data, &format).await; } Commands::Update { resource_type, id, data, + format, } => { let resource = resolve_resource_or_exit(&resource_type); let json_data = input::require_json_input(data.as_deref(), "update"); - commands::resource::update::run(&client, resource, &id, &json_data).await; + commands::resource::update::run(&client, resource, &id, &json_data, &format).await; } Commands::Delete { resource_type, @@ -292,9 +308,10 @@ async fn run() -> tb_prod::error::Result<()> { Commands::Search { resource_type, query, + format, } => { let resource = resolve_resource_or_exit(&resource_type); - commands::resource::search::run(&client, resource, &query).await; + commands::resource::search::run(&client, resource, &query, &format).await; } Commands::Action { resource_type, diff --git a/crates/tb-prod/src/schema.rs b/crates/tb-prod/src/schema.rs index 0dbed16..e1fd2f0 100644 --- a/crates/tb-prod/src/schema.rs +++ b/crates/tb-prod/src/schema.rs @@ -87,6 +87,8 @@ pub struct ResourceDef { pub aliases: Option>, pub endpoint: Option, pub query_hints: Option, + #[serde(default)] + pub default_filters: Option>, pub default_sort: Option, pub search_filter_param: Option, #[serde(default)] @@ -100,6 +102,8 @@ pub struct ResourceDef { #[serde(default)] pub collections: HashMap, pub cache: Option, + #[serde(default)] + pub display_columns: Option>, } impl ResourceDef { @@ -238,6 +242,22 @@ pub struct CustomAction { pub enabled_when: Option, } +// --- Display columns --- + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisplayColumn { + /// Column header label + pub label: String, + /// "attribute" or "relationship" + pub source: String, + /// For attributes: the JSON attribute key (e.g. "title", "due_date") + /// For relationships: the relationship name (e.g. "project", "assignee") + pub key: String, + /// For relationships: the target resource type (e.g. "projects", "people") + pub target: Option, +} + // --- Cache config --- #[derive(Debug, Deserialize)] diff --git a/scripts/benchmark-mcp-vs-cli.sh b/scripts/benchmark-mcp-vs-cli.sh index 9ec7a87..5eb9669 100755 --- a/scripts/benchmark-mcp-vs-cli.sh +++ b/scripts/benchmark-mcp-vs-cli.sh @@ -24,17 +24,22 @@ RUN_PREFIX="$RESULTS_DIR/${TIMESTAMP}-${TASK_NAME}" case "$TASK_NAME" in my_tasks) DESCRIPTION="List my Productive tasks assigned to me" - MCP_PROMPT="List all Productive.io tasks assigned to me using the mcp__productive__query_tasks tool. Show task title, status, and project. Do NOT use any CLI skills or Bash commands — only use MCP tools." - CLI_PROMPT="List all Productive.io tasks assigned to me. Use the Bash tool to run: tb-prod tasks. Show task title, status, and project. Do NOT use any MCP tools — only Bash." + MCP_PROMPT="List all Productive.io tasks assigned to me. Show task title, status, and project. Do NOT use any CLI skills or Bash commands — only use MCP tools (mcp__plugin_p-mcp-productive_productive__*)." + PRIME_CONTEXT=$(cd /tmp && tb-prod prime 2>&1) + CLI_PROMPT="List all Productive.io tasks assigned to me. IMPORTANT: Run all tb-prod commands from /tmp (e.g. 'cd /tmp && tb-prod ...'). Output defaults to CSV with resolved relationship names. Default filters auto-apply. Show task title, status, and project. Do NOT use any MCP tools — only Bash. + +Here is your tb-prod context (pre-loaded): + +$PRIME_CONTEXT" MCP_EXTRA_FLAGS=(--disable-slash-commands) - CLI_EXTRA_FLAGS=(--disallowed-tools "mcp__productive__query_tasks,mcp__productive__filter_tasks_by_prompt,mcp__productive__load_task_details") + CLI_EXTRA_FLAGS=(--disallowed-tools "mcp__plugin_p-mcp-productive_productive__query_resources,mcp__plugin_p-mcp-productive_productive__describe_resource,mcp__plugin_p-mcp-productive_productive__load_resource_details,mcp__plugin_p-mcp-productive_productive__search,mcp__plugin_p-mcp-productive_productive__search_resource,mcp__plugin_p-mcp-productive_productive__create_resource,mcp__plugin_p-mcp-productive_productive__update_resource,mcp__plugin_p-mcp-productive_productive__delete_resource,mcp__plugin_p-mcp-productive_productive__perform_resource_action,mcp__plugin_p-mcp-productive_productive__search_organization_data,mcp__plugin_p-mcp-productive_productive__search_project_data,mcp__plugin_p-mcp-productive_productive__load_current_context,mcp__plugin_p-mcp-productive_productive__describe_report,mcp__plugin_p-mcp-productive_productive__query_report,mcp__plugin_p-mcp-productive_productive__load_guides,mcp__plugin_p-mcp-productive_productive__search_guides,mcp__plugin_p-mcp-productive_productive__describe_skill,mcp__plugin_p-mcp-productive_productive__perform_skill_action,mcp__plugin_p-mcp-productive_productive__get_supported_currencies,mcp__plugin_p-mcp-productive_productive__read_file_from_url") ;; my_projects) DESCRIPTION="List my Productive projects" - MCP_PROMPT="List my Productive.io projects using MCP tools (mcp__productive__query_projects or mcp__productive__filter_projects_by_prompt). Show project name and status. Do NOT use any CLI skills or Bash commands — only use MCP tools." - CLI_PROMPT="List my Productive.io projects. Use the Bash tool to run: tb-prod projects. Show project name and status. Do NOT use any MCP tools — only Bash." + MCP_PROMPT="List my Productive.io projects. Show project name and status. Do NOT use any CLI skills or Bash commands — only use MCP tools (mcp__plugin_p-mcp-productive_productive__*)." + CLI_PROMPT="List my Productive.io projects. IMPORTANT: Run all tb-prod commands from /tmp (e.g. 'cd /tmp && tb-prod ...'). First run 'cd /tmp && tb-prod prime' to get your user context and command reference, then query projects with --format table. Show project name and status. Do NOT use any MCP tools — only Bash." MCP_EXTRA_FLAGS=(--disable-slash-commands) - CLI_EXTRA_FLAGS=(--disallowed-tools "mcp__productive__query_projects,mcp__productive__filter_projects_by_prompt,mcp__productive__load_project_details") + CLI_EXTRA_FLAGS=(--disallowed-tools "mcp__plugin_p-mcp-productive_productive__query_resources,mcp__plugin_p-mcp-productive_productive__describe_resource,mcp__plugin_p-mcp-productive_productive__load_resource_details,mcp__plugin_p-mcp-productive_productive__search,mcp__plugin_p-mcp-productive_productive__search_resource,mcp__plugin_p-mcp-productive_productive__create_resource,mcp__plugin_p-mcp-productive_productive__update_resource,mcp__plugin_p-mcp-productive_productive__delete_resource,mcp__plugin_p-mcp-productive_productive__perform_resource_action,mcp__plugin_p-mcp-productive_productive__search_organization_data,mcp__plugin_p-mcp-productive_productive__search_project_data,mcp__plugin_p-mcp-productive_productive__load_current_context,mcp__plugin_p-mcp-productive_productive__describe_report,mcp__plugin_p-mcp-productive_productive__query_report,mcp__plugin_p-mcp-productive_productive__load_guides,mcp__plugin_p-mcp-productive_productive__search_guides,mcp__plugin_p-mcp-productive_productive__describe_skill,mcp__plugin_p-mcp-productive_productive__perform_skill_action,mcp__plugin_p-mcp-productive_productive__get_supported_currencies,mcp__plugin_p-mcp-productive_productive__read_file_from_url") ;; *) echo "Unknown task: $TASK_NAME"