diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 296d4edc..eff24879 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1342,6 +1342,14 @@ func cmdAppStoreInstall(args []string) { } fmt.Println("note: the daemon rescans the install root periodically —") fmt.Println(" this app will be picked up within ~30s (no daemon restart needed)") + + // Last step: if this catalogue app ships a product demo in its + // sha-verified metadata, print it and drop a SKILL.md so the agent can + // drive the app right away. Best-effort and additive — no demo, or any + // failure fetching/rendering it, leaves the install above untouched. + if source == installSourceCatalogue { + maybeShowProductDemo(m.ID) + } } // pilotctlAuditFileName is the JSONL log of operator-initiated diff --git a/cmd/pilotctl/appstore_demo.go b/cmd/pilotctl/appstore_demo.go new file mode 100644 index 00000000..3777f9a7 --- /dev/null +++ b/cmd/pilotctl/appstore_demo.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Install-time "product demo" rendering. +// +// An app's catalogue metadata.json (fetched + sha-verified via +// catalogueEntry.MetadataURL/MetadataSHA — see appstore_metadata.go) may +// carry an optional `product_demo`: a compact, example-driven usage guide +// authored once at submit time. It is a superset of .help tuned for a +// small-context agent: one first call, a handful of worked examples, and — for +// metered apps — an explicit cost/budget line. +// +// The DATA (the demo itself) lives entirely in metadata.json; this file is only +// the INSERTION mechanism — a single pure renderer plus the last-step install +// hook. The two are decoupled: RenderInstallDemo is a pure function of its +// argument and knows nothing about any specific app, and maybeShowProductDemo +// only sources the data (via the sha-verified metadata) and prints it. +// +// Everything here is additive and best-effort: when `product_demo` is absent +// (the common case for existing apps), or malformed, install behaves exactly +// as before. The demo lives inside the already-sha-verified metadata.json, so +// it introduces no new trust surface. + +package main + +import ( + "fmt" + "strings" +) + +// ProductDemo mirrors the FROZEN `product_demo` object authors write in +// submission.json and that flows verbatim into catalogue metadata.json. Every +// field is optional at the wire level; a nil *ProductDemo means "no demo". +type ProductDemo struct { + Skill string `json:"skill"` + Title string `json:"title,omitempty"` + WhenToUse string `json:"when_to_use"` + Metered bool `json:"metered"` + Quickstart demoStep `json:"quickstart"` + Examples []demoStep `json:"examples"` + Cost *demoCost `json:"cost,omitempty"` + Gotchas []string `json:"gotchas,omitempty"` + Next []string `json:"next,omitempty"` +} + +// demoStep is one runnable example: a goal, the exact command, the output to +// expect, and (for metered spending calls) the cost of running it. +type demoStep struct { + Title string `json:"title,omitempty"` + Goal string `json:"goal,omitempty"` + Command string `json:"command"` + Expect string `json:"expect,omitempty"` + Cost string `json:"cost,omitempty"` + Note string `json:"note,omitempty"` +} + +// demoCost is the per-app cost breakdown for a metered app. +type demoCost struct { + Unit string `json:"unit"` + FreeBudget string `json:"free_budget"` + HardCapUSD float64 `json:"hard_cap_usd"` + Operations []demoCostOp `json:"operations"` + WorkedTotal string `json:"worked_total,omitempty"` + CheckBalance string `json:"check_balance,omitempty"` +} + +// demoCostOp is one row of the price table. +type demoCostOp struct { + Op string `json:"op"` + Price string `json:"price,omitempty"` + Note string `json:"note,omitempty"` +} + +// titleOr returns the demo title, defaulting to "Full usage demo". +func (d *ProductDemo) titleOr() string { + if strings.TrimSpace(d.Title) != "" { + return d.Title + } + return "Full usage demo" +} + +// RenderInstallDemo produces the compact banner pilotctl prints at the last +// step of a successful install: a headline, the first call, the worked +// examples, and (for metered apps) a one-line budget summary + balance check. +// Deterministic and side-effect free — a pure function of (appID, d). Returns +// "" for a nil demo. +func RenderInstallDemo(appID string, d *ProductDemo) string { + if d == nil { + return "" + } + var b strings.Builder + bar := strings.Repeat("─", 64) + fmt.Fprintf(&b, "%s\n", bar) + fmt.Fprintf(&b, " %s installed — %s\n", appID, d.titleOr()) + fmt.Fprintf(&b, "%s\n\n", bar) + if wt := strings.TrimSpace(d.WhenToUse); wt != "" { + fmt.Fprintf(&b, " When to use: %s\n\n", wt) + } + + if strings.TrimSpace(d.Quickstart.Command) != "" { + b.WriteString(" ▶ Run this first:\n") + fmt.Fprintf(&b, " %s\n", d.Quickstart.Command) + if d.Quickstart.Expect != "" { + fmt.Fprintf(&b, " → %s\n", oneLineDemo(d.Quickstart.Expect)) + } + b.WriteString("\n") + } + + if len(d.Examples) > 0 { + b.WriteString(" ▶ More examples:\n") + for _, ex := range d.Examples { + if ex.Title != "" { + fmt.Fprintf(&b, " # %s%s\n", ex.Title, demoCostSuffix(d.Metered, ex.Cost)) + } + if strings.TrimSpace(ex.Command) != "" { + fmt.Fprintf(&b, " %s\n", ex.Command) + } + } + b.WriteString("\n") + } + + if d.Metered && d.Cost != nil { + fmt.Fprintf(&b, " ▶ Budget: %s. ", strings.TrimSpace(d.Cost.FreeBudget)) + if strings.TrimSpace(d.Cost.WorkedTotal) != "" { + fmt.Fprintf(&b, "%s\n", strings.TrimSpace(d.Cost.WorkedTotal)) + } else { + b.WriteString("Reads are free; see the price table above.\n") + } + if strings.TrimSpace(d.Cost.CheckBalance) != "" { + fmt.Fprintf(&b, " balance: %s\n", d.Cost.CheckBalance) + } + b.WriteString("\n") + } + if len(d.Next) > 0 { + fmt.Fprintf(&b, " Full reference: %s\n", strings.Join(d.Next, " · ")) + } + fmt.Fprintf(&b, "%s\n", bar) + return b.String() +} + +// maybeShowProductDemo is the LAST-step hook of a successful catalogue install: +// if the installed app's sha-verified metadata carries a `product_demo`, print +// the install banner. It is fully best-effort — any failure (offline, malformed +// demo) is swallowed so a completed install is never turned into a failure. It +// sources the demo (data) and prints RenderInstallDemo (mechanism); the two +// stay decoupled. +func maybeShowProductDemo(appID string) { + defer func() { _ = recover() }() // a demo must never break a finished install + c, err := loadCatalogue() + if err != nil { + return + } + var entry *catalogueEntry + for i := range c.Apps { + if c.Apps[i].ID == appID { + entry = &c.Apps[i] + break + } + } + if entry == nil { + return + } + meta, err := loadAppMetadata(*entry) + if err != nil || meta == nil || meta.ProductDemo == nil { + return + } + if banner := RenderInstallDemo(appID, meta.ProductDemo); banner != "" { + fmt.Print(banner) + } +} + +func demoCostSuffix(metered bool, cost string) string { + if !metered || strings.TrimSpace(cost) == "" { + return "" + } + return " (" + strings.TrimSpace(cost) + ")" +} + +// oneLineDemo collapses newlines so a value is safe inside a single +// Markdown/terminal line. +func oneLineDemo(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + return strings.TrimSpace(s) +} diff --git a/cmd/pilotctl/appstore_demo_e2e_test.go b/cmd/pilotctl/appstore_demo_e2e_test.go new file mode 100644 index 00000000..36da7af1 --- /dev/null +++ b/cmd/pilotctl/appstore_demo_e2e_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// End-to-end test for the product-demo install render. The fixtures under +// testdata/e2e/ are REAL metadata.json files produced by app-template's +// production BuildMetadata path from the merged io.pilot.duckdb and +// io.pilot.agentphone submissions — not hand-written. This test drives them +// through the same appMetadata unmarshal + RenderInstallDemo that +// `pilotctl appstore install` runs at its last step, so a green run proves the +// whole chain: submission.json -> metadata.json -> the banner an agent sees. + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func e2eMetadata(t *testing.T, name string) *appMetadata { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "e2e", name)) + if err != nil { + t.Fatalf("read e2e fixture %s: %v", name, err) + } + var m appMetadata + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal %s into appMetadata: %v", name, err) + } + return &m +} + +func TestE2E_LocalDemoRender(t *testing.T) { + m := e2eMetadata(t, "duckdb.metadata.json") + if m.ProductDemo == nil { + t.Fatal("real duckdb metadata.json carried no product_demo through BuildMetadata") + } + out := RenderInstallDemo(m.ID, m.ProductDemo) + // The banner an agent sees must carry the real first call and its method. + if !strings.Contains(out, "io.pilot.duckdb installed") { + t.Errorf("missing install headline:\n%s", out) + } + if !strings.Contains(out, m.ProductDemo.Quickstart.Command) { + t.Errorf("banner missing the real quickstart command:\n%s", out) + } + if !strings.Contains(out, "duckdb.query") { + t.Errorf("banner missing a real duckdb method:\n%s", out) + } + // Non-metered app: no budget line. + if strings.Contains(out, "Budget:") { + t.Errorf("non-metered banner must not show a budget line:\n%s", out) + } +} + +func TestE2E_MeteredDemoRender(t *testing.T) { + m := e2eMetadata(t, "agentphone.metadata.json") + if m.ProductDemo == nil { + t.Fatal("real agentphone metadata.json carried no product_demo") + } + out := RenderInstallDemo(m.ID, m.ProductDemo) + for _, want := range []string{ + "io.pilot.agentphone installed", + m.ProductDemo.Quickstart.Command, + "agentphone.place_call", // a real worked-example method + "Budget:", // metered → budget line present + "$5.00 per Pilot user", // the real per-user budget + } { + if !strings.Contains(out, want) { + t.Errorf("metered banner missing %q:\n%s", want, out) + } + } + // The worked flow the banner advertises must stay within the $5 budget: sum + // the flat per-step dollar costs from the real demo. + var total float64 + for _, s := range append([]demoStep{m.ProductDemo.Quickstart}, m.ProductDemo.Examples...) { + total += leadingDollar(s.Cost) + } + if m.ProductDemo.Cost != nil && total > m.ProductDemo.Cost.HardCapUSD+1e-9 { + t.Errorf("real worked flow spends $%.2f, over the $%.2f budget", total, m.ProductDemo.Cost.HardCapUSD) + } +} + +// leadingDollar extracts the dollar amount from a step cost like "$0.10" or +// "$0.00 (read)". Returns 0 when there is no "$n" amount (e.g. "dynamic"). +func leadingDollar(cost string) float64 { + i := strings.Index(cost, "$") + if i < 0 { + return 0 + } + s := cost[i+1:] + end := 0 + for end < len(s) && (s[end] == '.' || (s[end] >= '0' && s[end] <= '9')) { + end++ + } + v, err := strconv.ParseFloat(s[:end], 64) + if err != nil { + return 0 + } + return v +} diff --git a/cmd/pilotctl/appstore_demo_test.go b/cmd/pilotctl/appstore_demo_test.go new file mode 100644 index 00000000..25b7555f --- /dev/null +++ b/cmd/pilotctl/appstore_demo_test.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// loadMetadataFixture parses a testdata metadata.json into appMetadata. +func loadMetadataFixture(t *testing.T, name string) *appMetadata { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + var m appMetadata + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal fixture %s: %v", name, err) + } + return &m +} + +// (a) metadata.json with a product_demo unmarshals into the new struct. +func TestProductDemoUnmarshal(t *testing.T) { + m := loadMetadataFixture(t, "metadata.metered.json") + if m.ProductDemo == nil { + t.Fatal("metered fixture: product_demo did not unmarshal (nil)") + } + d := m.ProductDemo + if d.Skill != "io.pilot.agentphone" { + t.Errorf("skill = %q, want io.pilot.agentphone", d.Skill) + } + if !d.Metered { + t.Error("metered fixture: Metered = false, want true") + } + if d.Cost == nil { + t.Fatal("metered fixture: Cost is nil") + } + if d.Cost.HardCapUSD != 5.0 { + t.Errorf("hard_cap_usd = %v, want 5.0", d.Cost.HardCapUSD) + } + if len(d.Cost.Operations) == 0 { + t.Error("metered fixture: cost operations table is empty") + } + if got := strings.TrimSpace(d.Quickstart.Command); !strings.HasPrefix(got, "pilotctl appstore call ") { + t.Errorf("quickstart command = %q, want copy-pasteable call", got) + } + if len(d.Examples) < 2 { + t.Errorf("examples len = %d, want at least 2", len(d.Examples)) + } + + // A local (non-metered) fixture must also decode, with no cost block. + ml := loadMetadataFixture(t, "metadata.local.json") + if ml.ProductDemo == nil { + t.Fatal("local fixture: product_demo did not unmarshal (nil)") + } + if ml.ProductDemo.Metered { + t.Error("local fixture: Metered = true, want false") + } + if ml.ProductDemo.Cost != nil { + t.Error("local fixture: Cost should be nil for a non-metered demo") + } +} + +// A metadata.json WITHOUT a product_demo leaves the field nil (the common, +// non-breaking case). +func TestProductDemoAbsent(t *testing.T) { + var m appMetadata + if err := json.Unmarshal([]byte(`{"schema_version":1,"id":"io.pilot.x"}`), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m.ProductDemo != nil { + t.Fatalf("ProductDemo should be nil when absent, got %+v", m.ProductDemo) + } + // And a nil demo renders to empty — never panics. + if s := RenderInstallDemo("io.pilot.x", m.ProductDemo); s != "" { + t.Errorf("RenderInstallDemo(nil) = %q, want empty", s) + } +} + +// (b) RenderInstallDemo over the METERED fixture: contains the quickstart +// command AND the budget line. +func TestRenderInstallDemoMetered(t *testing.T) { + d := loadMetadataFixture(t, "metadata.metered.json").ProductDemo + out := RenderInstallDemo("io.pilot.agentphone", d) + + if !strings.Contains(out, d.Quickstart.Command) { + t.Errorf("output missing quickstart command:\n%s", out) + } + if !strings.Contains(out, "Budget:") { + t.Errorf("metered output missing the cost/budget line:\n%s", out) + } + if !strings.Contains(out, "$5.00 per Pilot user") { + t.Errorf("metered output missing the free budget:\n%s", out) + } + if !strings.Contains(out, "balance:") { + t.Errorf("metered output missing the check-balance line:\n%s", out) + } + // The worked example commands should appear. + if !strings.Contains(out, "agentphone.place_call") { + t.Errorf("metered output missing a worked example command:\n%s", out) + } +} + +// (b, cont.) RenderInstallDemo over the NON-METERED fixture: contains the +// quickstart command but does NOT print a cost line. +func TestRenderInstallDemoLocal(t *testing.T) { + d := loadMetadataFixture(t, "metadata.local.json").ProductDemo + out := RenderInstallDemo("io.pilot.duckdb", d) + + if !strings.Contains(out, d.Quickstart.Command) { + t.Errorf("output missing quickstart command:\n%s", out) + } + if strings.Contains(out, "Budget:") { + t.Errorf("non-metered output should NOT contain a cost line:\n%s", out) + } + if strings.Contains(out, "balance:") { + t.Errorf("non-metered output should NOT contain a balance line:\n%s", out) + } +} + +// (c) RenderInstallDemo is a PURE function of its data — the demo content lives +// in metadata.json (the datasource), decoupled from this insertion mechanism. +// Same input → same output, and it embeds no app-specific content of its own. +func TestRenderInstallDemoIsPure(t *testing.T) { + d := loadMetadataFixture(t, "metadata.metered.json").ProductDemo + a := RenderInstallDemo("io.pilot.agentphone", d) + b := RenderInstallDemo("io.pilot.agentphone", d) + if a != b { + t.Error("RenderInstallDemo is not deterministic for equal input") + } + // Nothing in the render is hard-coded per app: a different appID + demo + // produces the other fixture's content, not agentphone's. + loc := loadMetadataFixture(t, "metadata.local.json").ProductDemo + other := RenderInstallDemo("io.pilot.duckdb", loc) + if strings.Contains(other, "agentphone") { + t.Error("renderer leaked app-specific content across demos") + } + if !strings.Contains(other, loc.Quickstart.Command) { + t.Error("renderer did not use the supplied demo's data") + } +} diff --git a/cmd/pilotctl/appstore_metadata.go b/cmd/pilotctl/appstore_metadata.go index 40e85ce3..43737a56 100644 --- a/cmd/pilotctl/appstore_metadata.go +++ b/cmd/pilotctl/appstore_metadata.go @@ -55,6 +55,13 @@ type appMetadata struct { Changelog []mdChangelog `json:"changelog,omitempty"` Links []mdLink `json:"links,omitempty"` + // ProductDemo is the optional, example-driven usage guide (see + // appstore_demo.go). When present it is rendered at the last step of a + // successful install and written as a SKILL.md; when absent (the common + // case) install is unaffected. It rides inside this already-sha-verified + // doc, so it adds no new trust surface. + ProductDemo *ProductDemo `json:"product_demo,omitempty"` + // Reviews is RESERVED for the future community-reviews service. It is // parsed if present but pilotctl never writes it today — reviews are a // separate, signed, dynamic service (see catalogue/README.md), not diff --git a/cmd/pilotctl/testdata/e2e/agentphone.metadata.json b/cmd/pilotctl/testdata/e2e/agentphone.metadata.json new file mode 100644 index 00000000..965ad26b --- /dev/null +++ b/cmd/pilotctl/testdata/e2e/agentphone.metadata.json @@ -0,0 +1,358 @@ +{ + "schema_version": 1, + "id": "io.pilot.agentphone", + "display_name": "AgentPhone", + "tagline": "A real phone number for your agent — voice calls, SMS/iMessage, and conversations over REST", + "description_md": "# AgentPhone — a real phone number for your AI agent\n\nAgentPhone gives your agent its **own real US/Canada phone number**: place and receive **voice calls**, send and receive **SMS \u0026 iMessage**, and hold threaded **conversations** with real people — all over plain REST. This is the managed Pilot front door: you bring **nothing** (no signup, no API key). Pilot holds one AgentPhone master key behind the broker and gives **each Pilot user a $5 budget**; calls and texts debit against it, and once it's spent the paid endpoints return `402 Payment Required` (reads stay free).\n\n## What you can do\n\n- **Call people.** `agentphone.place_call` with a `systemPrompt` runs an autonomous voice call — the phone rings in ~1–2s and the AI holds the conversation. Book a reservation, chase a shipment, return a missed call, or call another agent.\n- **Text people.** `agentphone.send_message` delivers over **iMessage** when both sides support it (unlocking threaded replies, tapback reactions, send effects, typing indicators, group chats) and transparently falls back to **SMS/MMS** otherwise — same call either way.\n- **Answer \u0026 follow up.** Poll `agentphone.list_number_messages` / `agentphone.list_conversation_messages` for inbound texts and `agentphone.get_call` for call transcripts — **no websockets required**.\n- **Manage your setup.** Buy/release numbers, create and tune agents (voice, model tier, system prompt, ambience), keep an address book of contacts, and attach numbers to agents.\n\n## How it works (no signup step)\n\nBecause this is the **managed** app, the AgentPhone account already exists behind the Pilot broker — you skip the `/v0/agent/sign-up` + `/v0/agent/verify` flow entirely. Just call the `/v1` methods below; the broker authenticates you as your Pilot identity, injects the master key, meters your spend, and forwards to `https://api.agentphone.ai`.\n\n**Async, poll-based (no streaming):**\n1. `agentphone.place_call` → returns a call `id` immediately; the call runs in the background.\n2. Poll `agentphone.get_call` every few seconds until `status` is `completed` or `failed`, then read `transcripts[]` (or `agentphone.get_transcript`).\n3. For inbound SMS, poll `agentphone.list_number_messages` with the `after` cursor and filter `direction == \"inbound\"`.\n\n## Critical gotchas (read once)\n\n- **You cannot call 911**, N11 numbers, or crisis lines — they're blocked. If your human has an emergency, tell them to dial directly.\n- **Released numbers are gone forever** — no refund for the unused month. Confirm before `agentphone.release_number`.\n- **Always use E.164**: `+14155551234` ✓ — never `(415) 555-1234` or `415-555-1234`. Assume `+1` for a bare US number and confirm if it matters.\n- **Inbound calls need hosted mode OR a webhook.** Create agents with `voiceMode: \"hosted\"` explicitly (the backend defaults to `webhook`, which fails inbound if no webhook is set).\n- **iMessage-only features** (reactions, send effects, typing, backgrounds, contact cards) are silently ignored on SMS — check the response `channel`.\n- **Don't spam.** Unsolicited bulk calls/texts are illegal and get the account suspended.\n\n## Cost \u0026 the $5 budget\n\nReads are free. Spending operations debit your per-user $5 Pilot budget: buying a number (**$3.00/mo**), placing a call (**per-minute**), and sending a text (**~$0.01–0.02**). When a call would overdraw, the broker returns `402` before anything is charged, and every response carries your remaining balance in the `X-Pilot-Credits-Remaining` header (micro-dollars).\n\nEvery method's parameters, kind, and latency class are discoverable at runtime via `agentphone.help`.\n", + "vendor": { + "name": "AgentPhone", + "url": "https://agentphone.ai", + "contact": "founders@agentphone.to" + }, + "homepage": "https://agentphone.ai", + "source_url": "https://github.com/AgentPhone-AI/skills", + "license": "Apache-2.0", + "categories": [ + "communication", + "phone", + "voice", + "messaging" + ], + "keywords": [ + "phone", + "sms", + "imessage", + "voice", + "calls", + "telephony", + "conversations", + "agent" + ], + "size": { + "bundle_bytes": 0, + "installed_bytes": 0 + }, + "compat": { + "min_pilot_version": "1.10.0", + "runtimes": [ + "go" + ] + }, + "methods": [ + { + "name": "agentphone.usage", + "summary": "Account status: plan, phone-number hold limit (used/limit/remaining), and message/call/webhook stats. Call this first to orient in a session. Read-only; no charge." + }, + { + "name": "agentphone.usage_daily", + "summary": "Daily usage breakdown for the last N days (max 365). Read-only." + }, + { + "name": "agentphone.usage_monthly", + "summary": "Monthly usage aggregation. Read-only." + }, + { + "name": "agentphone.usage_by_number", + "summary": "Usage broken down per phone number. Read-only." + }, + { + "name": "agentphone.usage_by_agent", + "summary": "Usage broken down per agent over a period. Read-only." + }, + { + "name": "agentphone.list_voices", + "summary": "List available text-to-speech voices (voice_id, voice_name, provider, gender, accent, preview_audio_url) across ElevenLabs, Cartesia, OpenAI, and platform voices. gender/accent/preview may be null — do not crash on missing fields. Use voice_id when creating/updating an agent. Read-only." + }, + { + "name": "agentphone.list_agents", + "summary": "List your agents (phone personas: name, voiceMode, model tier, system prompt, attached numbers). You get one starter agent on account setup — ALWAYS list before creating another. Read-only." + }, + { + "name": "agentphone.create_agent", + "summary": "Create an agent (phone persona). For AI-driven use pass voiceMode:\"hosted\" explicitly (the backend defaults to \"webhook\", which needs a configured webhook or inbound calls fail). systemPrompt is required for hosted. Pick a voice from agentphone.list_voices. Free (no telephony spend)." + }, + { + "name": "agentphone.get_agent", + "summary": "Get one agent's full config and its attached numbers. Read-only." + }, + { + "name": "agentphone.update_agent", + "summary": "Update an agent — only the fields you send change. Any create field is updatable (systemPrompt, voice, modelTier, …). Free." + }, + { + "name": "agentphone.delete_agent", + "summary": "Delete an agent. Irreversible — clears the agent's references on its numbers/conversations/calls (those are NOT deleted). Confirm with your human first. Free." + }, + { + "name": "agentphone.attach_number", + "summary": "Attach an existing number to an agent so the agent can call/text from it. Free." + }, + { + "name": "agentphone.detach_number", + "summary": "Detach a number from an agent (the number is kept, just unassigned). Free." + }, + { + "name": "agentphone.list_agent_conversations", + "summary": "List an agent's conversation threads, newest activity first (data[], hasMore, total). Read-only." + }, + { + "name": "agentphone.list_agent_calls", + "summary": "List an agent's calls (data[], hasMore, total). Read-only." + }, + { + "name": "agentphone.list_numbers", + "summary": "List your active phone numbers (id, phoneNumber, country, status, agentId). Read-only." + }, + { + "name": "agentphone.buy_number", + "summary": "Provision a new US/CA phone number. COSTS $3.00 from your $5 Pilot budget (402 if it would overdraw). The provisioned number is saved to this host's ~/.pilot/.agentphone so you can recall it later with agentphone.mynumber — no need to store it yourself. Optionally attach to an agent and request an area code." + }, + { + "name": "agentphone.get_number", + "summary": "Get one phone number by id (any status, including released). Read-only." + }, + { + "name": "agentphone.release_number", + "summary": "Release (delete) a number. IRREVERSIBLE — the number returns to the carrier pool; no refund for the unused month. Confirm with your human first. Free to call." + }, + { + "name": "agentphone.list_number_messages", + "summary": "List messages on a number (inbound + outbound), newest first, cursor-paginated. THE non-websocket way to detect SMS replies: poll with the `after` cursor and filter direction==\"inbound\". Read-only." + }, + { + "name": "agentphone.list_number_calls", + "summary": "List calls on a number. Read-only." + }, + { + "name": "agentphone.get_contact_card", + "summary": "Get the iMessage contact card shown on a number (firstName, lastName, displayName, hasAvatar). iMessage only. Read-only." + }, + { + "name": "agentphone.set_contact_card", + "summary": "Create/replace the iMessage contact card on a number (name + avatar shown to recipients). iMessage only. Free." + }, + { + "name": "agentphone.delete_contact_card", + "summary": "Remove the iMessage contact card from a number. Free." + }, + { + "name": "agentphone.send_message", + "summary": "Send an SMS/iMessage. COSTS MONEY (~$0.01–0.02, debited from your $5 budget → 402 if over). Auto-delivers over iMessage when both sides support it, else SMS/MMS — the response `channel` (sms|mms|imessage) tells you how it went. E.164 for `to_number` (or a group id grp_… for an iMessage group). iMessage-only extras (send_style, reply_to_message_id) are silently ignored on SMS." + }, + { + "name": "agentphone.react", + "summary": "Send a tapback reaction to a message. iMessage ONLY — returns 400 on SMS. Free." + }, + { + "name": "agentphone.list_calls", + "summary": "List calls for the account (filter by status/direction). Read-only." + }, + { + "name": "agentphone.place_call", + "summary": "Place an OUTBOUND voice call. COSTS MONEY (per-minute, ~$0.05+, debited from your $5 budget → 402 if over). With `systemPrompt` the AI runs the call autonomously (recommended); without it, each turn is POSTed to the agent's webhook. Returns a call id IMMEDIATELY (async) — the phone rings in a second or two. Then POLL agentphone.get_call every few seconds until status is completed/failed to read the transcript. Cannot call 911 / N11 / crisis lines (blocked)." + }, + { + "name": "agentphone.get_call", + "summary": "Get a call and its embedded transcripts[]. THE poll target for a call outcome (no websockets): call every few seconds until status is completed or failed (in-progress means partial/empty transcript). Also carries durationSeconds, startedAt, endedAt. Read-only." + }, + { + "name": "agentphone.end_call", + "summary": "Terminate an in-progress call. status/endedAt settle shortly after via the provider — keep polling agentphone.get_call until terminal. Free." + }, + { + "name": "agentphone.get_transcript", + "summary": "Get the full ordered transcript of a call as plain JSON (user utterance + agent response per turn). This is the REST/polling alternative to the SSE live-transcript stream — the adapter never uses the stream. Read-only." + }, + { + "name": "agentphone.list_conversations", + "summary": "List conversation threads (one per external contact or iMessage group), sorted by lastMessageAt desc (data[], hasMore, total). Read-only." + }, + { + "name": "agentphone.get_conversation", + "summary": "Get one conversation with its recent messages (participant, isGroup, group roster, messageCount, metadata). Read-only." + }, + { + "name": "agentphone.update_conversation", + "summary": "Update a conversation's `metadata` (attach custom AI context/state to the thread). Free." + }, + { + "name": "agentphone.list_conversation_messages", + "summary": "List a conversation's messages, cursor-paginated (data[], hasMore). Poll with `after` to catch new inbound replies in a thread. Read-only." + }, + { + "name": "agentphone.typing", + "summary": "Show a typing indicator before you reply. iMessage only, best-effort, auto-expires (no stop call). Free." + }, + { + "name": "agentphone.set_background", + "summary": "Set a chat background image for a conversation. iMessage only. Free." + }, + { + "name": "agentphone.clear_background", + "summary": "Clear a conversation's chat background. iMessage only. Free." + }, + { + "name": "agentphone.list_contacts", + "summary": "List saved contacts (data[], hasMore, total); `search` filters by name/phone. Read-only." + }, + { + "name": "agentphone.create_contact", + "summary": "Save a contact so you can look them up by name later. phoneNumber is normalized to E.164; returns 409 if the phone already exists. Free." + }, + { + "name": "agentphone.get_contact", + "summary": "Get one contact by id. Read-only." + }, + { + "name": "agentphone.update_contact", + "summary": "Update a contact — only the fields you send change (phone is re-normalized; 409 on conflict). Free." + }, + { + "name": "agentphone.delete_contact", + "summary": "Delete a contact. Confirm with your human first. Free." + }, + { + "name": "agentphone.get_webhook", + "summary": "Get the account-level webhook config. Read-only. (Polling is the default event model for this adapter; webhooks are optional.)" + }, + { + "name": "agentphone.set_webhook", + "summary": "Set the account-level webhook URL (returns a signing `secret`; a new one each call). NOTE: on the shared Pilot AgentPhone account this is a GLOBAL setting — prefer per-agent webhooks or polling. Free." + }, + { + "name": "agentphone.delete_webhook", + "summary": "Remove the account-level webhook. Global on the shared account — use with care. Free." + }, + { + "name": "agentphone.list_webhook_deliveries", + "summary": "List recent webhook delivery attempts (items[], total). Read-only." + }, + { + "name": "agentphone.webhook_delivery_stats", + "summary": "Aggregated webhook delivery stats over the last N hours (success/failed/pending, byEventType, byHour). Read-only." + }, + { + "name": "agentphone.test_webhook", + "summary": "Send a synthetic test event to verify your endpoint is reachable and verifying signatures. Free." + }, + { + "name": "agentphone.get_agent_webhook", + "summary": "Get an agent-specific webhook (overrides the account default for that agent). Read-only." + }, + { + "name": "agentphone.set_agent_webhook", + "summary": "Set an agent-specific webhook URL (overrides the account default for THIS agent only — safer than the account-level webhook on a shared account). Free." + }, + { + "name": "agentphone.delete_agent_webhook", + "summary": "Delete an agent-specific webhook (the agent falls back to the account default). Free." + }, + { + "name": "agentphone.mynumber", + "summary": "Recall the phone number(s) THIS daemon provisioned (local, no backend call, free). Reads ~/.pilot/.agentphone, populated automatically by agentphone.buy_number. Returns {entries:[{id,phoneNumber,status,agentId,...}]} — empty if this host hasn't provisioned one yet. Use it to find 'my number' without listing the shared account." + }, + { + "name": "agentphone.help", + "summary": "Discovery: every method with params, kind, and latency class." + } + ], + "changelog": [ + { + "version": "0.3.0", + "notes": [ + "Released v0.3.0" + ] + } + ], + "links": [ + { + "label": "Source", + "url": "https://github.com/AgentPhone-AI/skills" + }, + { + "label": "Website", + "url": "https://agentphone.ai" + } + ], + "product_demo": { + "skill": "io.pilot.agentphone", + "title": "Full usage demo", + "when_to_use": "When your agent needs to place a real phone call or send an SMS/iMessage to a person — bookings, reminders, follow-ups, chasing a shipment or a missed call.", + "metered": true, + "quickstart": { + "goal": "Orient — check your account and $5 budget (free read)", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.usage '{}'", + "expect": "{\"plan\":\"managed\",\"numbers\":{\"used\":0,\"limit\":1},\"stats\":{...}}", + "cost": "$0.00 (read)" + }, + "examples": [ + { + "title": "Find your starter agent (free read)", + "goal": "Get the agentId you place calls / send texts as", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.list_agents '{}'", + "expect": "{\"data\":[{\"id\":\"agent_...\",\"name\":\"Assistant\",\"voiceMode\":\"hosted\"}]}", + "cost": "$0.00 (read)" + }, + { + "title": "Place an autonomous voice call", + "goal": "The AI dials and holds the conversation from your prompt", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.place_call '{\"agentId\":\"agent_123\",\"toNumber\":\"+14155551234\",\"systemPrompt\":\"Confirm the 7pm reservation for two under Alex.\"}'", + "expect": "{\"id\":\"call_...\",\"status\":\"queued\"}", + "cost": "$0.10", + "note": "Returns immediately; poll agentphone.get_call for the transcript." + }, + { + "title": "Poll the call transcript (free read)", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.get_call '{\"call_id\":\"call_...\"}'", + "expect": "{\"status\":\"completed\",\"durationSeconds\":42,\"transcripts\":[...]}", + "cost": "$0.00 (read)" + }, + { + "title": "Send a text (iMessage → SMS fallback)", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.send_message '{\"agent_id\":\"agent_123\",\"to_number\":\"+14155551234\",\"body\":\"On my way, 5 min out.\"}'", + "expect": "{\"id\":\"msg_...\",\"channel\":\"imessage\"}", + "cost": "$0.02" + } + ], + "cost": { + "unit": "micro-USD (1000000 = $1.00)", + "free_budget": "$5.00 per Pilot user", + "hard_cap_usd": 5, + "operations": [ + { + "op": "agentphone.place_call", + "price": "$0.10", + "note": "per call (per-minute, ~$0.05+ for a short call)" + }, + { + "op": "agentphone.send_message", + "price": "$0.02", + "note": "per SMS/iMessage" + }, + { + "op": "agentphone.buy_number", + "price": "$3.00", + "note": "per month; released numbers are gone forever, no refund" + }, + { + "op": "agentphone.usage / list_* / get_call / get_transcript", + "price": "$0.00", + "note": "all reads are free" + } + ], + "worked_total": "This demo spends $0.12 of your $5.00 budget (1 call + 1 text; the two reads are free).", + "check_balance": "pilotctl appstore call io.pilot.agentphone agentphone.usage '{}'" + }, + "gotchas": [ + "Always use E.164 numbers (+14155551234) — never (415) 555-1234.", + "You cannot dial 911, N11, or crisis lines — they are blocked.", + "402 Payment Required means your $5.00 budget is spent — reads still work.", + "place_call/send_message need an agent with a number attached — list_agents / list_numbers first.", + "Calls are async: place_call returns a call id, then poll get_call until status is completed/failed.", + "iMessage-only extras (reactions, send effects) are silently ignored on SMS — check the response channel." + ], + "next": [ + "io.pilot.agentphone agentphone.help '{}'" + ] + } +} diff --git a/cmd/pilotctl/testdata/e2e/duckdb.metadata.json b/cmd/pilotctl/testdata/e2e/duckdb.metadata.json new file mode 100644 index 00000000..07d5acb8 --- /dev/null +++ b/cmd/pilotctl/testdata/e2e/duckdb.metadata.json @@ -0,0 +1,155 @@ +{ + "schema_version": 1, + "id": "io.pilot.duckdb", + "display_name": "DuckDB", + "tagline": "Run DuckDB from an agent — in-process analytical SQL over files, no server, no provisioning", + "description_md": "# DuckDB — in-process analytical SQL, native CLI for agents\n\nThis app installs the official **DuckDB 1.5.4** command-line shell on the host and fronts it as typed\nmethods. The bundle is the upstream DuckDB CLI binary (sha-pinned per OS/arch, fetched from the Pilot\nartifact registry at install) plus a tiny wrapper that serves a clean, complete `--help`.\n\nDuckDB is an **in-process** OLAP database — \"SQLite for analytics.\" There is **no server, no daemon, no\nport, no auth, and nothing to provision**: an agent opens an in-memory database (`:memory:`) or a single\n`.duckdb` file like any other file. That posture is exactly right for an autonomous agent working in its\nown sandbox with no cloud account.\n\n## Why an agent wants this\n\n- **Zero provisioning.** No `initdb`, no server lifecycle, no credentials, no \"is the daemon up?\" state.\n Run SQL against `:memory:` or a file and you're done.\n- **Query files in place — the killer feature.** Point SQL straight at CSV, Parquet, or JSON on disk with\n no load step: `SELECT region, sum(amount) FROM '/data/*.parquet' GROUP BY region`. The file *is* the table.\n- **Fast analytics on a laptop.** A columnar, vectorized engine: aggregations and joins over millions of\n rows run quickly in a single process, in the agent's own context.\n- **Agent-friendly output.** Get results as an aligned table, **CSV**, **JSON**, or **Markdown** — pick the\n shape that parses cleanly or drops straight into a report.\n- **Full SQL.** Window functions, CTEs, nested types (lists/structs/maps), `COPY` to/from Parquet/CSV, and\n a rich function library.\n- **Self-contained + offline.** One binary, no dependencies; the core CSV/Parquet/JSON readers are built in,\n so the common cases need no network and no extensions.\n\n## Methods\n\n- `duckdb.query` — run SQL, get an aligned `box` table (default). In-memory or against a file.\n- `duckdb.query_csv` / `duckdb.query_json` / `duckdb.query_markdown` — same, as CSV / JSON / Markdown.\n- `duckdb.file` — execute a `.sql` script file (migrations, ETL, multi-statement setup).\n- `duckdb.tables` — list every table and view (`SHOW ALL TABLES`).\n- `duckdb.schema` — print the `CREATE` DDL for the database (`.schema`).\n- `duckdb.exec` — run the CLI with a verbatim argv (+ optional stdin) for any flag, output mode, or\n dot-command the curated methods don't cover (`.mode`, `.import`, `.export`, `.read`, `-init`, …).\n- `duckdb.cli_help` — the complete CLI help: every option **and** every dot-command, as clean text.\n- `duckdb.version` — the delivered DuckDB version. `duckdb.help` — the self-describing method list.\n\n## How to use it\n\n1. **Quick analysis (no setup):** `duckdb.query` `{ \"database\": \":memory:\", \"sql\": \"SELECT count(*) FROM '/data/events.parquet'\" }`.\n2. **Persistent database:** point `database` at a file path (`/work/app.duckdb`); it's created on first use and\n persists. The same file holds many tables/schemas.\n3. **Parse the output:** use `duckdb.query_json` (array of row objects) or `duckdb.query_csv`.\n4. **Anything else:** `duckdb.exec` `{ \"args\": [\":memory:\", \"-cmd\", \".import /data/in.csv t\", \"-c\", \"SELECT count(*) FROM t\"] }`.\n\n## Configuration\n\n- **`database`** — `:memory:` (ephemeral, no provisioning) or an absolute path to a `.duckdb`/`.db` file\n (created on first use, persists, holds many tables). Independent of this, the SQL can read/write CSV,\n Parquet, and JSON files anywhere on disk.\n- **Output mode** — choose the method (`query` box / `query_csv` / `query_json` / `query_markdown`), or any\n other mode via `duckdb.exec` (`-line`, `-html`, `-ascii`, `-jsonlines`, …).\n- **Extensions** — Parquet/CSV/JSON readers are built in. Network-backed extensions (e.g. `httpfs` for S3/HTTP\n Parquet) are loadable via `duckdb.exec` (`INSTALL httpfs; LOAD httpfs;`) where the host allows egress; the\n local-only path needs neither.\n- **Read-only** — open a database without write access via `duckdb.exec` (`-readonly`).\n\n## Good to know\n\n- Output returns verbatim where it's already clean; on a non-zero exit (SQL error) the reply is\n `{stdout, stderr, exit}` so the caller sees everything the CLI produced.\n- Runs on **macOS and Linux** (arm64 + amd64); the binary is fetched from the Pilot artifact registry and\n sha-pinned on install. Free and open source under the **MIT License**.\n- `duckdb.help` lists every method with its latency class — the self-describing discovery contract.\n\n## DuckDB CLI help (`duckdb.cli_help`)\n```\nDuckDB CLI — command-line options and meta-commands\n===================================================\n\nDuckDB is an in-process analytical SQL database (think \"SQLite for analytics\").\nThe CLI runs SQL against an in-memory database (use ':memory:') or a database\nfile, and can query CSV, Parquet, and JSON files directly with no import step,\ne.g. SELECT * FROM 'data/*.parquet' WHERE ... — the file IS the table.\n\nUSAGE: duckdb [OPTIONS] [DATABASE_FILE] [SQL]\n\nDATABASE_FILE is a DuckDB database; it is created if it does not exist.\nUse ':memory:' for an ephemeral in-memory database.\n\n------------------------------------------------------------------------------\nCOMMAND-LINE OPTIONS (duckdb -help)\n------------------------------------------------------------------------------\n\nOPTIONS:\n -ascii set output mode to 'ascii'\n -bail stop after hitting an error\n -batch force batch I/O'\n -box set output mode to 'box'\n -column set output mode to 'column'\n -cmd COMMAND run \"COMMAND\" before reading stdin\n -csv set output mode to 'csv'\n -c COMMAND run \"COMMAND\" and exit\n -dark-mode use dark mode colors\n -echo print commands before execution\n -f FILENAME read/process named file and exit\n -init FILENAME read/process named file\n -header turn headers on\n -h show help message\n -help show help message\n -html set output mode to HTML\n -interactive force interactive I/O\n -json set output mode to 'json'\n -jsonlines set output mode to 'jsonlines'\n -light-mode use light mode colors\n -line set output mode to 'line'\n -list set output mode to 'list'\n -markdown set output mode to 'markdown'\n -newline SEP set output row separator. Default: '\\n'\n -no-init skip processing the init file\n -no-stdin exit after processing options instead of reading stdin\n -noheader turn headers off\n -nullvalue TEXT set text string for NULL values. Default 'NULL'\n -quote set output mode to 'quote'\n -readonly open the database read-only\n -s COMMAND run \"COMMAND\" and exit\n -safe enable safe-mode\n -separator SEP set output column separator. Default: '|'\n -storage-version VER database storage compatibility version to use. Default: 'v0.10.0'\n -table set output mode to 'table'\n -ui launches a web interface using the ui extension (configurable with .ui_command)\n -unredacted allow printing unredacted secrets\n -unsigned allow loading of unsigned extensions\n -version show DuckDB version\n\n------------------------------------------------------------------------------\nDOT-COMMANDS (meta-commands; usable inside the shell or via -cmd \"...\")\n------------------------------------------------------------------------------\n.bail on|off Stop after hitting an error. Default OFF\n.binary on|off Turn binary output on or off. Default OFF\n.cd DIRECTORY Change the working directory to DIRECTORY\n.changes on|off Show number of rows changed by SQL\n.columns Column-wise rendering of query results\n.decimal_sep SEP Sets the decimal separator used when rendering numbers. Only for duckbox mode.\n.databases List names and files of attached databases\n.dump ?TABLE? Render database content as SQL\n.display_colors [bold|underline] Display all terminal colors and their names\n.echo on|off Turn command echo on or off\n.edit Opens an external text editor to edit a query.\n.excel Display the output of next command in spreadsheet\n.exit ?CODE? Exit this program with return-code CODE\n.headers on|off Turn display of headers on or off\n.help ?-all? ?PATTERN? Show help text for PATTERN\n.highlight on|off Toggle syntax highlighting in the shell on/off\n.highlight_colors OPTIONS Configure highlighting colors\n.highlight_errors on|off Turn highlighting of errors on or off\n.highlight_mode mixed|dark|light Toggle the highlight mode to dark or light mode\n.highlight_results on|off Turn highlighting of results on or off\n.import FILE TABLE Import data from FILE into TABLE\n.indexes ?TABLE? Show names of indexes\n.last Render the last result without truncating\n.large_number_rendering MODE Toggle readable rendering of large numbers (duckbox only)\n.log FILE|off Turn logging on or off. FILE can be stderr/stdout\n.maxrows COUNT Sets the maximum number of rows for display (default: 40). Only for duckbox mode.\n.maxwidth COUNT Sets the maximum width in characters. 0 defaults to terminal width. Only for duckbox mode.\n.mode MODE ?TABLE? Set output mode\n.multiline Sets the render mode to multi-line\n.nullvalue STRING Use STRING in place of NULL values\n.open ?OPTIONS? ?FILE? Close existing database and reopen FILE\n.once ?FILE? Output for the next SQL command only to FILE\n.output ?FILE? Send output to FILE or stdout if FILE is omitted\n.pager OPTIONS Control pager usage for output\n.print STRING... Print literal STRING\n.progress_bar OPTIONS Configure the progress bar display\n.prompt MAIN CONTINUE Replace the standard prompts\n.quit Exit this program\n.read FILE Read input from FILE\n.read_line_version linenoise|fallback Sets the library used for processing interactive input\n.render_completion on|off Toggle displaying of completion prompts in the shell on/off\n.render_errors on|off Toggle rendering of errors in the shell on/off\n.rows Row-wise rendering of query results (default)\n.safe_mode Enable safe-mode\n.separator COL ?ROW? Change the column and row separators\n.schema ?PATTERN? Show the CREATE statements matching PATTERN\n.shell CMD ARGS... Run CMD ARGS... in a system shell\n.show Show the current values for various settings\n.singleline Sets the render mode to single-line\n.startup_text none|version|all Start-up text to display. Set this as the first line in .duckdbrc\n.system CMD ARGS... Run CMD ARGS... in a system shell\n.tables ?TABLE? List names of tables matching LIKE pattern TABLE\n.thousand_sep SEP Sets the thousand separator used when rendering numbers. Only for duckbox mode.\n.timer on|off Turn SQL timer on or off\n.ui_command [command] Set the UI command\n.version Show the version\n.width NUM1 NUM2 ... Set minimum column widths for columnar output\n\nRun .help --all for extended information\nRun .help shortcuts for keyboard shortcuts\n```\n", + "vendor": { + "name": "Pilot Protocol", + "url": "https://pilotprotocol.network", + "contact": "apps@pilotprotocol.network" + }, + "homepage": "https://duckdb.org", + "source_url": "https://github.com/duckdb/duckdb", + "license": "MIT", + "categories": [ + "database", + "data", + "sql", + "analytics" + ], + "keywords": [ + "duckdb", + "sql", + "olap", + "analytics", + "parquet", + "csv", + "json", + "query", + "in-process", + "columnar", + "database", + "embedded" + ], + "size": { + "bundle_bytes": 0, + "installed_bytes": 0 + }, + "compat": { + "min_pilot_version": "1.0.0", + "runtimes": [ + "go" + ] + }, + "methods": [ + { + "name": "duckdb.query", + "summary": "Run a SQL statement (or `;`-separated batch) and return an aligned `box` table — the default, human-readable shape. Works in-memory (`database=\":memory:\"`) or against a DuckDB file, and can query CSV/Parquet/JSON files in place, e.g. `SELECT region, sum(amount) FROM '/data/*.parquet' GROUP BY region`. This is `duckdb \u003cdatabase\u003e -box -c \u003csql\u003e`." + }, + { + "name": "duckdb.query_csv", + "summary": "Same as duckdb.query but returns the result set as CSV (header + rows) — the right shape when an agent needs to parse the output. This is `duckdb \u003cdatabase\u003e -csv -c \u003csql\u003e`." + }, + { + "name": "duckdb.query_json", + "summary": "Run SQL and return the result set as a JSON array of row objects — the most directly machine-parseable output. This is `duckdb \u003cdatabase\u003e -json -c \u003csql\u003e`." + }, + { + "name": "duckdb.query_markdown", + "summary": "Run SQL and return the result set as a GitHub-flavored Markdown table — handy when the output is going straight into a report or PR comment. This is `duckdb \u003cdatabase\u003e -markdown -c \u003csql\u003e`." + }, + { + "name": "duckdb.file", + "summary": "Execute a `.sql` script file against the database and exit — for multi-statement setups, migrations, or ETL scripts the agent has written to disk. This is `duckdb \u003cdatabase\u003e -f \u003cfile\u003e`." + }, + { + "name": "duckdb.tables", + "summary": "List every table and view across all attached databases/schemas (name, database, schema, column list) as a `box` table — a quick inventory of what a DuckDB file holds. This is `duckdb \u003cdatabase\u003e -box -c \"SHOW ALL TABLES\"`." + }, + { + "name": "duckdb.schema", + "summary": "Print the `CREATE` statements for every table, view, and index in the database — the DDL, via DuckDB's `.schema` meta-command. This is `duckdb \u003cdatabase\u003e -no-stdin -cmd \".schema\"`." + }, + { + "name": "duckdb.exec", + "summary": "Run the DuckDB CLI with a verbatim argv — the full surface beyond the curated methods. Payload is {\"args\":[...]} (the args passed straight to `duckdb`) plus optional {\"stdin\":\"...\"} piped to the process. Use it for any flag or meta-command the curated methods don't cover: a different output mode (`-line`, `-html`, `-ascii`), reading a script with `-init`, a `.mode`/`.import`/`.read` dot-command via `-cmd`, or a multi-statement session over stdin. Examples: {\"args\":[\":memory:\",\"-line\",\"-c\",\"SELECT 1\"]}; {\"args\":[\"/data/app.duckdb\",\"-csv\",\"-cmd\",\".import /data/in.csv t\",\"-c\",\"SELECT count(*) FROM t\"]}; {\"args\":[\":memory:\"],\"stdin\":\"CREATE TABLE t(x int);\\nINSERT INTO t VALUES (1),(2);\\nSELECT sum(x) FROM t;\"}." + }, + { + "name": "duckdb.cli_help", + "summary": "Return the complete DuckDB CLI help — every command-line option AND every dot-command (`.mode`, `.tables`, `.schema`, `.read`, `.import`, `.export`, `.timer`, …) — captured verbatim from the delivered binary and rendered as clean, color-free text. The full reference for what duckdb.query / duckdb.exec accept." + }, + { + "name": "duckdb.version", + "summary": "Print the delivered DuckDB version, e.g. \"v1.5.4 (Variegata) 08e34c447b\". Needs no database. This is `duckdb -version`." + }, + { + "name": "duckdb.help", + "summary": "Discovery: every method with params, kind, and latency class." + } + ], + "changelog": [ + { + "version": "1.5.4", + "notes": [ + "Released v1.5.4" + ] + } + ], + "links": [ + { + "label": "Source", + "url": "https://github.com/duckdb/duckdb" + }, + { + "label": "Website", + "url": "https://duckdb.org" + } + ], + "product_demo": { + "skill": "io.pilot.duckdb", + "title": "Full usage demo", + "when_to_use": "When you need an in-process OLAP/SQL engine to query CSV, Parquet or JSON files and crunch analytics locally with zero server — not for concurrent writes or a long-lived shared database.", + "metered": false, + "quickstart": { + "goal": "Run your first query (no provisioning — in-memory)", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query '{\"database\":\":memory:\",\"sql\":\"SELECT 42 AS answer\"}'", + "expect": "an aligned box table with one column `answer` and value 42" + }, + "examples": [ + { + "title": "Aggregate a CSV file in place — no import step", + "goal": "Group and count straight over a file on disk", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query_json '{\"database\":\":memory:\",\"sql\":\"SELECT country, count(*) AS n FROM read_csv_auto('/data/users.csv') GROUP BY 1 ORDER BY n DESC\"}'", + "expect": "JSON array of row objects, e.g. [{\"country\":\"US\",\"n\":120},{\"country\":\"DE\",\"n\":44}]" + }, + { + "title": "Create and populate a persistent table", + "goal": "Persist data to a .duckdb file on disk", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query '{\"database\":\"/work/sales.duckdb\",\"sql\":\"CREATE TABLE IF NOT EXISTS sales(id INT, amt DECIMAL); INSERT INTO sales VALUES (1,9.99),(2,4.50); SELECT sum(amt) AS total FROM sales\"}'", + "expect": "box table with total = 14.49; the sales table survives in /work/sales.duckdb" + }, + { + "title": "Read a Parquet file as Markdown", + "goal": "Preview columnar data formatted for a report", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query_markdown '{\"database\":\":memory:\",\"sql\":\"SELECT * FROM read_parquet('/data/events.parquet') LIMIT 5\"}'", + "expect": "a GitHub-flavored Markdown table of the first 5 rows" + }, + { + "title": "List tables in a database", + "goal": "See what exists before querying", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.tables '{\"database\":\"/work/sales.duckdb\"}'", + "expect": "one row per table/view: name, database, schema" + } + ], + "gotchas": [ + "File paths (read_csv_auto, read_parquet, .duckdb files) resolve inside the app sandbox, not your shell CWD.", + "`database` is required on every query — use \":memory:\" for throwaway work or an absolute .duckdb path to persist.", + ":memory: databases vanish when the call returns; nothing is saved.", + "Single-writer engine: great for analytics, not for many concurrent writers." + ], + "next": [ + "io.pilot.duckdb duckdb.help '{}'" + ] + } +} diff --git a/cmd/pilotctl/testdata/metadata.local.json b/cmd/pilotctl/testdata/metadata.local.json new file mode 100644 index 00000000..1a0465aa --- /dev/null +++ b/cmd/pilotctl/testdata/metadata.local.json @@ -0,0 +1,52 @@ +{ + "schema_version": 1, + "id": "io.pilot.duckdb", + "display_name": "DuckDB", + "description_md": "fixture", + "product_demo": { + "skill": "io.pilot.duckdb", + "title": "Full usage demo", + "when_to_use": "When you need an in-process OLAP/SQL engine to query CSV, Parquet or JSON files and crunch analytics locally with zero server — not for concurrent writes or a long-lived shared database.", + "metered": false, + "quickstart": { + "goal": "Run your first query (no provisioning — in-memory)", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query '{\"database\":\":memory:\",\"sql\":\"SELECT 42 AS answer\"}'", + "expect": "an aligned box table with one column `answer` and value 42" + }, + "examples": [ + { + "title": "Aggregate a CSV file in place — no import step", + "goal": "Group and count straight over a file on disk", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query_json '{\"database\":\":memory:\",\"sql\":\"SELECT country, count(*) AS n FROM read_csv_auto('/data/users.csv') GROUP BY 1 ORDER BY n DESC\"}'", + "expect": "JSON array of row objects, e.g. [{\"country\":\"US\",\"n\":120},{\"country\":\"DE\",\"n\":44}]" + }, + { + "title": "Create and populate a persistent table", + "goal": "Persist data to a .duckdb file on disk", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query '{\"database\":\"/work/sales.duckdb\",\"sql\":\"CREATE TABLE IF NOT EXISTS sales(id INT, amt DECIMAL); INSERT INTO sales VALUES (1,9.99),(2,4.50); SELECT sum(amt) AS total FROM sales\"}'", + "expect": "box table with total = 14.49; the sales table survives in /work/sales.duckdb" + }, + { + "title": "Read a Parquet file as Markdown", + "goal": "Preview columnar data formatted for a report", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.query_markdown '{\"database\":\":memory:\",\"sql\":\"SELECT * FROM read_parquet('/data/events.parquet') LIMIT 5\"}'", + "expect": "a GitHub-flavored Markdown table of the first 5 rows" + }, + { + "title": "List tables in a database", + "goal": "See what exists before querying", + "command": "pilotctl appstore call io.pilot.duckdb duckdb.tables '{\"database\":\"/work/sales.duckdb\"}'", + "expect": "one row per table/view: name, database, schema" + } + ], + "gotchas": [ + "File paths (read_csv_auto, read_parquet, .duckdb files) resolve inside the app sandbox, not your shell CWD.", + "`database` is required on every query — use \":memory:\" for throwaway work or an absolute .duckdb path to persist.", + ":memory: databases vanish when the call returns; nothing is saved.", + "Single-writer engine: great for analytics, not for many concurrent writers." + ], + "next": [ + "io.pilot.duckdb duckdb.help '{}'" + ] + } +} \ No newline at end of file diff --git a/cmd/pilotctl/testdata/metadata.metered.json b/cmd/pilotctl/testdata/metadata.metered.json new file mode 100644 index 00000000..b7e2df03 --- /dev/null +++ b/cmd/pilotctl/testdata/metadata.metered.json @@ -0,0 +1,87 @@ +{ + "schema_version": 1, + "id": "io.pilot.agentphone", + "display_name": "AgentPhone", + "description_md": "fixture", + "product_demo": { + "skill": "io.pilot.agentphone", + "title": "Full usage demo", + "when_to_use": "When your agent needs to place a real phone call or send an SMS/iMessage to a person — bookings, reminders, follow-ups, chasing a shipment or a missed call.", + "metered": true, + "quickstart": { + "goal": "Orient — check your account and $5 budget (free read)", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.usage '{}'", + "expect": "{\"plan\":\"managed\",\"numbers\":{\"used\":0,\"limit\":1},\"stats\":{...}}", + "cost": "$0.00 (read)" + }, + "examples": [ + { + "title": "Find your starter agent (free read)", + "goal": "Get the agentId you place calls / send texts as", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.list_agents '{}'", + "expect": "{\"data\":[{\"id\":\"agent_...\",\"name\":\"Assistant\",\"voiceMode\":\"hosted\"}]}", + "cost": "$0.00 (read)" + }, + { + "title": "Place an autonomous voice call", + "goal": "The AI dials and holds the conversation from your prompt", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.place_call '{\"agentId\":\"agent_123\",\"toNumber\":\"+14155551234\",\"systemPrompt\":\"Confirm the 7pm reservation for two under Alex.\"}'", + "expect": "{\"id\":\"call_...\",\"status\":\"queued\"}", + "cost": "$0.10", + "note": "Returns immediately; poll agentphone.get_call for the transcript." + }, + { + "title": "Poll the call transcript (free read)", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.get_call '{\"call_id\":\"call_...\"}'", + "expect": "{\"status\":\"completed\",\"durationSeconds\":42,\"transcripts\":[...]}", + "cost": "$0.00 (read)" + }, + { + "title": "Send a text (iMessage → SMS fallback)", + "command": "pilotctl appstore call io.pilot.agentphone agentphone.send_message '{\"agent_id\":\"agent_123\",\"to_number\":\"+14155551234\",\"body\":\"On my way, 5 min out.\"}'", + "expect": "{\"id\":\"msg_...\",\"channel\":\"imessage\"}", + "cost": "$0.02" + } + ], + "cost": { + "unit": "micro-USD (1000000 = $1.00)", + "free_budget": "$5.00 per Pilot user", + "hard_cap_usd": 5.0, + "operations": [ + { + "op": "agentphone.place_call", + "price": "$0.10", + "note": "per call (per-minute, ~$0.05+ for a short call)" + }, + { + "op": "agentphone.send_message", + "price": "$0.02", + "note": "per SMS/iMessage" + }, + { + "op": "agentphone.buy_number", + "price": "$3.00", + "note": "per month; released numbers are gone forever, no refund" + }, + { + "op": "agentphone.usage / list_* / get_call / get_transcript", + "price": "$0.00", + "note": "all reads are free" + } + ], + "worked_total": "This demo spends $0.12 of your $5.00 budget (1 call + 1 text; the two reads are free).", + "check_balance": "pilotctl appstore call io.pilot.agentphone agentphone.usage '{}'" + }, + "gotchas": [ + "Always use E.164 numbers (+14155551234) — never (415) 555-1234.", + "You cannot dial 911, N11, or crisis lines — they are blocked.", + "402 Payment Required means your $5.00 budget is spent — reads still work.", + "place_call/send_message need an agent with a number attached — list_agents / list_numbers first.", + "Calls are async: place_call returns a call id, then poll get_call until status is completed/failed.", + "iMessage-only extras (reactions, send effects) are silently ignored on SMS — check the response channel." + ], + "next": [ + "io.pilot.agentphone agentphone.help '{}'" + ] + } +} \ No newline at end of file