From cb8b9dc5d087484cbcd7d39368b03326b1088c5a Mon Sep 17 00:00:00 2001 From: Calin Teodor Date: Thu, 8 Oct 2026 15:43:42 +0300 Subject: [PATCH 1/2] scaffold: cli apps find a user-installed CLI outside the daemon's PATH The daemon runs under launchd or systemd with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin on macOS), and apps inherit it. A CLI the user installed in ~/.local/bin (DoorDash's dd-cli, pipx) or with Homebrew runs from their shell but failed in the app with "executable file not found in $PATH". When the base command is a bare name PATH cannot resolve, the generated adapter now looks in ~/.local/bin, /opt/homebrew/bin, /usr/local/bin and ~/bin before giving up. Paths and staged assets are unchanged. Co-Authored-By: Claude Opus 5.5 --- internal/scaffold/cli_user_bin_e2e_test.go | 111 ++++++++++++++++++ .../scaffold/templates/client_cli.go.tmpl | 35 ++++++ 2 files changed, 146 insertions(+) create mode 100644 internal/scaffold/cli_user_bin_e2e_test.go diff --git a/internal/scaffold/cli_user_bin_e2e_test.go b/internal/scaffold/cli_user_bin_e2e_test.go new file mode 100644 index 0000000..a21a35e --- /dev/null +++ b/internal/scaffold/cli_user_bin_e2e_test.go @@ -0,0 +1,111 @@ +//go:build !windows + +package scaffold + +import ( + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// A CLI the user installed in ~/.local/bin is found although the adapter's +// PATH lacks that directory. The daemon runs under launchd or systemd with +// PATH=/usr/bin:/bin:/usr/sbin:/sbin, so a bare command name that works in +// the user's shell failed in the app with "executable file not found". +func TestCLIAdapterFindsCommandInUserBinDir(t *testing.T) { + if testing.Short() { + t.Skip("builds and runs a real adapter binary; skipped under -short") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not available") + } + + root := t.TempDir() + home := filepath.Join(root, "home") + userBin := filepath.Join(home, ".local", "bin") + if err := os.MkdirAll(userBin, 0o755); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\necho \"user-bin: $*\"\n" + if err := os.WriteFile(filepath.Join(userBin, "userbintool"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + cfg := parseSpec(t, ` +id: io.pilot.userbintool +app_version: 0.1.0 +description: "Fronts a CLI installed in ~/.local/bin." +namespace: userbintool +backend: + type: cli + command: ["userbintool"] +methods: + - name: userbintool.run + summary: "Passthrough." + cli: {passthrough: true} +`) + proj := filepath.Join(root, "proj") + if _, err := Generate(cfg, proj); err != nil { + t.Fatalf("generate: %v", err) + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + _ = os.WriteFile(filepath.Join(proj, "go.sum"), sum, 0o644) + } + bin := filepath.Join(root, "adapter") + build := exec.Command("go", "build", "-o", bin, "./cmd/"+cfg.BinaryName) + build.Dir = proj + build.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build adapter: %v\n%s", err, out) + } + + // Unix socket paths are capped at 104 bytes on macOS; this test's TempDir + // name is too long for one. + sockDir, err := os.MkdirTemp("", "ub") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + sock := filepath.Join(sockDir, "app.sock") + adapter := exec.Command(bin, "--socket", sock, "--manifest", filepath.Join(proj, "manifest.json")) + // The environment a launchd-started daemon hands its apps. + adapter.Env = []string{"HOME=" + home, "PATH=/usr/bin:/bin:/usr/sbin:/sbin", "TMPDIR=" + os.TempDir()} + adapter.Stderr = os.Stderr + if err := adapter.Start(); err != nil { + t.Fatalf("start adapter: %v", err) + } + defer func() { _ = adapter.Process.Kill(); _, _ = adapter.Process.Wait() }() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(sock); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + + conn, err := net.DialTimeout("unix", sock, 3*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + var out json.RawMessage + if err := ipc.Call(conn, "userbintool.run", json.RawMessage(`{"args":["hello"]}`), &out); err != nil { + t.Fatalf("call: %v", err) + } + var got struct { + Stdout string `json:"stdout"` + Exit int `json:"exit"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("reply %s: %v", out, err) + } + if got.Stdout != "user-bin: hello" || got.Exit != 0 { + t.Fatalf("got %+v, want the tool in ~/.local/bin to run", got) + } +} diff --git a/internal/scaffold/templates/client_cli.go.tmpl b/internal/scaffold/templates/client_cli.go.tmpl index e6b276b..4d4d272 100644 --- a/internal/scaffold/templates/client_cli.go.tmpl +++ b/internal/scaffold/templates/client_cli.go.tmpl @@ -38,6 +38,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "regexp" "sort" "strings" @@ -136,6 +137,7 @@ func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage argv0 = p } } + argv0 = findCommand(argv0) cmd := exec.CommandContext(ctx, argv0, args...) cmd.Env = r.childEnv() @@ -195,6 +197,39 @@ func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage return wrapped, nil } +// userBinDirs are where per-user installers put a CLI (DoorDash's dd-cli and +// pipx use ~/.local/bin; Homebrew uses /opt/homebrew/bin or /usr/local/bin). +// The daemon that spawns this adapter usually runs under launchd or systemd +// with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin on macOS), so a CLI the +// user installed and runs fine from their shell is not found by name. +var userBinDirs = []string{"~/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", "~/bin"} + +// findCommand returns name unchanged when it is a path or PATH resolves it; +// otherwise the first executable of that name in userBinDirs, or name (so the +// spawn error still names the command the user has to install). +func findCommand(name string) string { + if strings.ContainsRune(name, filepath.Separator) { + return name + } + if _, err := exec.LookPath(name); err == nil { + return name + } + home, _ := os.UserHomeDir() + for _, dir := range userBinDirs { + if strings.HasPrefix(dir, "~/") { + if home == "" { + continue + } + dir = filepath.Join(home, dir[2:]) + } + p := filepath.Join(dir, name) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 { + return p + } + } + return name +} + // childEnv builds the scrubbed environment for the child: the minimal baseline // plus any explicitly opted-in keys that are actually set in the host env. func (r *Runner) childEnv() []string { From 4eeda85ddde61cd53787e08c526d133de682d38d Mon Sep 17 00:00:00 2001 From: Calin Teodor Date: Thu, 8 Oct 2026 15:46:45 +0300 Subject: [PATCH 2/2] submissions: DoorDash CLI (Beta), not listed io.pilot.doordash 0.2.5 fronts the user's own DoorDash CLI (dd-cli, DoorDash's waitlist-only beta): a verbatim-argv passthrough plus cli_help and version. It does not ship dd-cli, whose CLI Access Terms make it personal-use and non-redistributable. Marked beta everywhere, links DoorDash's get-started guide, states the waitlist, the terms and that checkout charges a real card. Not for merge yet: merging publishes it, and listing it needs DoorDash's permission first (CLI terms 4.1(b)). Co-Authored-By: Claude Opus 5.5 --- .../io.pilot.doordash/ab-commands.json | 44 +++++ submissions/io.pilot.doordash/submission.json | 178 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 submissions/io.pilot.doordash/ab-commands.json create mode 100644 submissions/io.pilot.doordash/submission.json diff --git a/submissions/io.pilot.doordash/ab-commands.json b/submissions/io.pilot.doordash/ab-commands.json new file mode 100644 index 0000000..4a899c9 --- /dev/null +++ b/submissions/io.pilot.doordash/ab-commands.json @@ -0,0 +1,44 @@ +[ + { + "label": "version", + "vanilla": [ + "--version" + ], + "method": "doordash.version", + "payload": {} + }, + { + "label": "cli_help", + "vanilla": [ + "--help" + ], + "method": "doordash.cli_help", + "payload": {} + }, + { + "label": "exec_root_help", + "vanilla": [ + "--help" + ], + "method": "doordash.exec", + "payload": { + "args": [ + "--help" + ] + } + }, + { + "label": "exec_search_help", + "vanilla": [ + "search", + "--help" + ], + "method": "doordash.exec", + "payload": { + "args": [ + "search", + "--help" + ] + } + } +] diff --git a/submissions/io.pilot.doordash/submission.json b/submissions/io.pilot.doordash/submission.json new file mode 100644 index 0000000..5213cdd --- /dev/null +++ b/submissions/io.pilot.doordash/submission.json @@ -0,0 +1,178 @@ +{ + "id": "io.pilot.doordash", + "version": "0.2.5", + "description": "BETA. DoorDash CLI (dd-cli 0.2.5) fronted as Pilot methods. dd-cli is DoorDash's own waitlist-only beta; this app does not ship it. It runs the dd-cli you install yourself, signed in to your own DoorDash account: search restaurants and stores, browse menus, build a cart, preview pricing and check out. Checkout places a real order on your saved payment method. Personal use only, under DoorDash's CLI terms. dd-cli ships for macOS Apple Silicon and Linux x86_64. Not affiliated with or endorsed by DoorDash. Get started: https://developer.doordash.com/en-US/docs/cli/tutorials/get_started/", + "email": "apps@pilotprotocol.network", + "backend": { + "type": "cli", + "command": [ + "dd-cli" + ], + "env_passthrough": [ + "DD_CLI_ACCESS_TOKEN", + "DD_CLI_CA_BUNDLE" + ] + }, + "methods": [ + { + "name": "doordash.exec", + "description": "Run dd-cli with a verbatim argv: the whole tool. Payload {\"args\":[...]} is passed straight to `dd-cli`, plus optional {\"stdin\":\"...\"}. Put --json-output before the command for structured fields. Every command that reaches DoorDash needs --intent, a plain-language goal; run [\"\",\"--help\"] for its flags (help also needs sign-in). Checkout places a real order on the user's saved payment method. Examples: {\"args\":[\"search\",\"--help\"]}; {\"args\":[\"--json-output\",\"order\",\"--help\"]}.", + "latency": "slow", + "params": [ + { + "name": "args", + "type": "array", + "required": true, + "description": "Verbatim argv passed to `dd-cli`, e.g. [\"--json-output\",\"search\",\"--help\"]." + }, + { + "name": "stdin", + "type": "string", + "required": false, + "description": "Optional text piped to dd-cli's stdin." + } + ], + "cli": { + "passthrough": true + } + }, + { + "name": "doordash.cli_help", + "description": "dd-cli's root help: the --json-output option, headless sign-in with DD_CLI_ACCESS_TOKEN, and the command list (search, menu, cart, order, address, payment-method, promo, …). Needs no sign-in. This is `dd-cli --help`.", + "latency": "fast", + "cli": { + "args": [ + "--help" + ] + } + }, + { + "name": "doordash.version", + "description": "The installed dd-cli version, e.g. \"dd-cli, version 0.2.5\". Needs no sign-in; use it to check dd-cli is installed and found. This is `dd-cli --version`.", + "latency": "fast", + "cli": { + "args": [ + "--version" + ] + } + } + ], + "listing": { + "display_name": "DoorDash CLI (Beta)", + "tagline": "BETA: order from DoorDash through your own dd-cli. Waitlist-only, personal use, real orders.", + "app_description": "# DoorDash CLI (Beta)\n\n> **Beta.** DoorDash CLI (`dd-cli`) is DoorDash's own **waitlist-only beta**. Full functionality needs an account approved off DoorDash's waitlist. This app is a thin adapter that runs **your own** `dd-cli`; it does not ship `dd-cli`, and it is not affiliated with or endorsed by DoorDash.\n\n## Before you install\n\n1. **Get access and install dd-cli** by following DoorDash's guide: **[Get started with DoorDash CLI](https://developer.doordash.com/en-US/docs/cli/tutorials/get_started/)**. Releases are on [GitHub](https://github.com/doordash-oss/doordash-cli/releases). DoorDash's installer puts it at `~/.local/bin/dd-cli`, which this app finds even when the Pilot daemon's `PATH` does not include that directory.\n2. **Sign in once in your own terminal:** `dd-cli login` (browser sign-in; the token is saved in your OS keychain). On a headless Linux host, run `dd-cli export-token` on a machine with a browser and set the printed value as `DD_CLI_ACCESS_TOKEN` in the Pilot daemon's environment.\n3. dd-cli is available for **macOS Apple Silicon** and **Linux x86_64**. The adapter runs everywhere, but calls fail on other platforms until DoorDash ships dd-cli there.\n\n## What dd-cli can do (DoorDash's list)\n\n- Addresses: list saved addresses, set the default address\n- Payment methods: list saved payment methods (cards)\n- Store search: restaurants, grocery and more, nearby stores, store details\n- Menu search: browse menus and catalogs, item details\n- Carts: individual or group; add and remove items; build from a shopping list\n- Promotions and work benefits\n- Ordering: preview pricing, tip, credits, submit an order or get a browser checkout URL, pick-up or delivery, ASAP or scheduled\n- Order history and receipts\n\n## Real orders, real money\n\nCheckout places a **real order** and charges the account's saved payment method. DoorDash's CLI terms say it may not ask for confirmation before an order is finalized. Agents should confirm the order with the user before checking out, or use `order checkout-url` to hand the user a browser checkout link instead.\n\n## Terms\n\ndd-cli is governed by DoorDash's **CLI Access Terms** (shipped as `LICENSE.txt` in every dd-cli release): personal, non-commercial use of **your own** DoorDash account. Your agent acts as your representative, and you are responsible for the orders it places.\n\n## Methods\n\n- `doordash.exec`: run dd-cli with a verbatim argv (the whole tool)\n- `doordash.cli_help`: dd-cli's root `--help` (no sign-in needed)\n- `doordash.version`: dd-cli's version (no sign-in needed)\n\nEvery command that reaches DoorDash needs `--intent` (a plain-language statement of the goal), and `--json-output` goes before the command for structured results. A command's own `--help` needs sign-in.\n\n## dd-cli --help\n\n```\nUsage: dd-cli [OPTIONS] COMMAND [ARGS]...\n\n Order food on DoorDash from your terminal.\n\n Most commands require sign-in — run `dd-cli login` once. Credentials are\n saved in your operating system keychain and reused automatically. Every\n service command requires saved credentials; only `login` bypasses this gate.\n If a call fails because the token has expired, re-run `dd-cli login` (or\n refresh the DD_CLI_ACCESS_TOKEN env var if running in a headless environment\n — see below).\n\n Headless environments (containers, servers, CI) have no keychain and no\n browser for sign-in. Instead, the user should obtain the token on a desktop\n machine, then use that to set the DD_CLI_ACCESS_TOKEN env var in the\n headless environment. When set, the DD_CLI_ACCESS_TOKEN env var takes\n precedence over the keychain; leave it unset for normal desktop use.\n\n Optional: Setting the DD_CLI_CA_BUNDLE environment variable verifies server\n certificates against a custom CA bundle PEM file instead of the default OS\n certificate store, for every command (sign-in included).\n\n For agents — terminal context, no UI to render:\n If a tool response carries fields named `widget_type` or\n `assistant_instructions`, or text inside any field referring to\n \"the widget above\" / \"interactive widget\" / similar UI-rendering\n language, treat those as inapplicable. To access the structured\n data fields (items, store_name, prices, fees, addresses,\n delivery times, etc.), run with `--json-output` (e.g.\n `dd-cli --json-output search ...`) and respond from those —\n default output is text-rendered.\n\nOptions:\n --json-output Print the full structured result from each command as JSON\n (machine-readable). By default text responses omit the raw\n envelope when prettifying embedded JSON.\n --version Show the version and exit.\n -h, --help Show this message and exit.\n\nCommands:\n address List or change the consumer's saved delivery...\n build-grocery-list Build a grocery shopping list.\n cart Manage a personal or group cart: add, show,...\n export-token Sign in and print an access token for a...\n find-items Search items within a retail or grocery store...\n find-nearby-stores Discover nearby stores within a 16-mile...\n item-details Show retail/grocery item details (pricing,\n description, options).\n login Sign in with your DoorDash account in the...\n menu Show menu for a restaurant.\n order Browse order history and move a cart through...\n payment-method List the consumer's saved payment methods.\n promo Operations on promotions applicable to a store.\n restaurant-item-details Show full details (pricing, description,...\n search Find nearby restaurants.\n store-details Show store details (name, image, business...\n```\n", + "license": "Proprietary", + "homepage": "https://developer.doordash.com/en-US/docs/cli/tutorials/get_started/", + "source_url": "https://github.com/doordash-oss/doordash-cli", + "categories": [ + "commerce", + "food", + "cli" + ], + "keywords": [ + "doordash", + "dd-cli", + "food-delivery", + "ordering", + "groceries", + "restaurants", + "beta" + ] + }, + "vendor": { + "name": "Pilot Protocol", + "url": "https://pilotprotocol.network", + "contact": "apps@pilotprotocol.network", + "agent_usage": "Check dd-cli is installed with doordash.version, read the command list with doordash.cli_help, then drive dd-cli through doordash.exec with a verbatim argv: --json-output first, --intent on every command that reaches DoorDash, and a command's --help to learn its flags. Confirm with the user before any checkout.", + "capabilities": "A thin adapter over the user's own DoorDash CLI (dd-cli, DoorDash's waitlist-only beta), signed in to the user's own DoorDash account. Does not ship dd-cli and is not affiliated with DoorDash." + }, + "product_demo": { + "skill": "io.pilot.doordash", + "title": "Full usage demo", + "when_to_use": "When the user wants to order food, groceries or retail items from DoorDash with their own account, and they have installed and signed in to dd-cli (DoorDash's waitlist-only beta).", + "metered": false, + "quickstart": { + "goal": "Check dd-cli is installed and see its commands (no sign-in needed)", + "command": "pilotctl appstore call io.pilot.doordash doordash.cli_help '{}'", + "expect": "dd-cli's root help: --json-output, headless sign-in, and the command list (search, menu, cart, order, …)" + }, + "examples": [ + { + "title": "Installed version", + "command": "pilotctl appstore call io.pilot.doordash doordash.version '{}'", + "expect": "\"dd-cli, version 0.2.5\" in stdout" + }, + { + "title": "Learn a command's flags (signed in)", + "command": "pilotctl appstore call io.pilot.doordash doordash.exec '{\"args\":[\"search\",\"--help\"]}'", + "expect": "search's options, including --intent; before sign-in, a missing-credentials error with exit 1", + "note": "DoorDash's own guidance: navigate with --help, root first, then only the group and command you need." + }, + { + "title": "Order commands (signed in)", + "command": "pilotctl appstore call io.pilot.doordash doordash.exec '{\"args\":[\"order\",\"--help\"]}'", + "expect": "the order subcommands; DoorDash's quickstart shows history, reorder and checkout-url" + }, + { + "title": "Structured results", + "command": "pilotctl appstore call io.pilot.doordash doordash.exec '{\"args\":[\"--json-output\",\"order\",\"--help\"]}'", + "expect": "the same help; on a real command, --json-output before it returns the full JSON result" + } + ], + "gotchas": [ + "Beta and waitlist-only: install dd-cli yourself (this app does not ship it) and run `dd-cli login` once in your own terminal.", + "Every command that reaches DoorDash needs --intent, a plain-language goal for the workflow. Read [\"\",\"--help\"] for its format.", + "Put --json-output before the command for structured fields: [\"--json-output\",\"search\",...].", + "Checkout places a real order on the user's saved card, possibly with no confirmation step. Confirm first, or use `order checkout-url` to give the user a browser link.", + "\"missing credentials\": the user must run `dd-cli login`, or set DD_CLI_ACCESS_TOKEN (from `dd-cli export-token`) in the daemon's environment on a headless host.", + "dd-cli exists for macOS Apple Silicon and Linux x86_64 only. Personal use of the user's own account only (DoorDash CLI terms)." + ], + "next": [ + "io.pilot.doordash doordash.help '{}'" + ] + }, + "next_steps": { + "schema": 1, + "app": "io.pilot.doordash", + "edges": [ + { + "from": "*", + "on": "ok", + "match": "missing credentials", + "why": "dd-cli is installed but not signed in (exit 1 in the body). Only the user can sign in: `dd-cli login` in their own terminal, or DD_CLI_ACCESS_TOKEN on a headless host", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.doordash doordash.exec '{\"args\":[\"search\",\"--help\"]}'", + "why": "after the user has signed in, retry: this is the first command that needs an account", + "kind": "recovery" + } + ] + }, + { + "from": "*", + "on": "err", + "match": "executable file not found", + "why": "dd-cli is not installed. The user installs it from DoorDash's guide (https://developer.doordash.com/en-US/docs/cli/tutorials/get_started/); this app looks in ~/.local/bin as well as PATH", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.doordash doordash.version '{}'", + "why": "after installing, check the app finds it: expect \"dd-cli, version ...\"", + "kind": "recovery" + } + ] + }, + { + "from": "doordash.cli_help", + "on": "ok", + "why": "pick the command for the task and read its own help before using it", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.doordash doordash.exec '{\"args\":[\"search\",\"--help\"]}'", + "why": "search is where most orders start; its help lists the flags and the --intent format", + "kind": "flow" + } + ] + } + ] + } +}