@ashlr/stack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashlr / Mason Wyatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @ashlr/stack
2
+
3
+ > The `stack` CLI — one command to provision, wire, and operate every third-party service in your project. 23 providers, MCP-native, Phantom-backed.
4
+
5
+ Part of [Ashlr Stack](https://stack.ashlr.ai). See the monorepo at [ashlrai/ashlr-stack](https://github.com/ashlrai/ashlr-stack).
6
+
7
+ ## What it is
8
+
9
+ `stack` collapses the "spin up ten services" phase of a project into a single command. Instead of tab-hopping between Supabase, Vercel, Sentry, Neon, etc. to create projects and paste secrets into `.env`, you run:
10
+
11
+ ```bash
12
+ stack init --template nextjs-supabase-posthog-sentry
13
+ ```
14
+
15
+ Stack does the OAuth dance per provider, creates the upstream resource, stores every secret in [Phantom Secrets](https://phantom.ashlr.ai), writes `.env` + `.mcp.json`, and hands you a project ready for `bun dev`.
16
+
17
+ Secrets never live in Stack — only Phantom. `.stack.toml` is safe to commit; per-developer IDs live in the gitignored `.stack.local.toml`.
18
+
19
+ ## Install
20
+
21
+ One-liner (recommended):
22
+
23
+ ```bash
24
+ # macOS / Linux
25
+ curl -fsSL stack.ashlr.ai/install.sh | bash
26
+
27
+ # Windows (PowerShell)
28
+ irm stack.ashlr.ai/install.ps1 | iex
29
+ ```
30
+
31
+ Or install manually from the registry:
32
+
33
+ ```bash
34
+ bun add -g @ashlr/stack ashlr-stack-mcp # preferred
35
+ # or
36
+ npm i -g @ashlr/stack ashlr-stack-mcp
37
+ ```
38
+
39
+ Prerequisites:
40
+
41
+ - [Bun](https://bun.sh) >= 1.2 (or Node.js >= 20)
42
+ - [Phantom Secrets](https://phantom.ashlr.ai) — the installer sets this up for you
43
+
44
+ ## Quickstart
45
+
46
+ ```bash
47
+ stack init # interactive template picker
48
+ stack add supabase # OAuth → new project → secrets → .mcp.json
49
+ stack add # no arg = interactive provider picker
50
+ stack doctor # verify every wired service is healthy
51
+ stack doctor --all # run across every registered project
52
+ stack list # show this project's stack at a glance
53
+ stack providers # full catalog of 23 curated providers
54
+ ```
55
+
56
+ Bring Stack to an existing repo:
57
+
58
+ ```bash
59
+ stack scan # detect providers already in the code
60
+ stack scan --auto # scans, then runs `stack add` for each
61
+ stack import # inhale an existing .env into Phantom
62
+ stack clone github.com/org/repo # clone + scan someone else's project
63
+ ```
64
+
65
+ Run your app with secrets injected by Phantom:
66
+
67
+ ```bash
68
+ stack exec -- bun dev
69
+ ```
70
+
71
+ ## Commands
72
+
73
+ 22 commands total. A cheat sheet:
74
+
75
+ | Command | What it does |
76
+ | --- | --- |
77
+ | `stack init [--template]` | Scaffold a new stack from a template |
78
+ | `stack add [service]` | Add a provider (OAuth + provision + write secrets) |
79
+ | `stack remove <service>` | Tear down one service (or `--all` for everything) |
80
+ | `stack list` | Show services wired into this project |
81
+ | `stack info <service>` | Deep-dive on one service (resource, region, docs, MCP name) |
82
+ | `stack doctor [--all] [--fix] [--json]` | Healthcheck; `--fix` re-runs setup, `--json` for CI |
83
+ | `stack scan [--auto]` | Detect providers from package manifests, configs, `.env.example` |
84
+ | `stack import [--from <file>]` | Migrate an existing `.env` into Phantom + `.stack.toml` |
85
+ | `stack clone <github-url>` | git-clone + scan + next-steps |
86
+ | `stack providers` | Browse the full 23-provider catalog |
87
+ | `stack templates` | List starter templates |
88
+ | `stack deps` | ASCII tree of this stack grouped by category |
89
+ | `stack env show` / `stack env diff` | Effective env-var surface vs vault vs config |
90
+ | `stack env set <KEY> <VALUE>` / `stack env unset <KEY>` | Direct secret injection |
91
+ | `stack exec -- <cmd>` | Run a command with Phantom secrets in the environment |
92
+ | `stack projects list|register|remove|where` | Cross-project registry |
93
+ | `stack ci init` | Scaffold a `.github/workflows/stack-ci.yml` |
94
+ | `stack completion <bash\|zsh\|fish>` | Emit a shell-completion script |
95
+ | `stack upgrade` | Check npm for a newer `@ashlr/stack` release |
96
+
97
+ Full reference with flags, subcommands, and examples: **[stack.ashlr.ai/docs/cli](https://stack.ashlr.ai/docs/cli)**.
98
+
99
+ ## MCP integration
100
+
101
+ `stack add` writes to your project's `.mcp.json` so Claude Code / Cursor / Windsurf can use the provider's MCP server immediately. See [ashlr-stack-mcp](https://www.npmjs.com/package/ashlr-stack-mcp) for Stack's own MCP server (17 tools + 3 resources) and [stack.ashlr.ai/docs/mcp](https://stack.ashlr.ai/docs/mcp) for wiring docs.
102
+
103
+ ## Configuration
104
+
105
+ Stack splits config into two files:
106
+
107
+ - `.stack.toml` — **committed**. Service names, secret slots, MCP wirings.
108
+ - `.stack.local.toml` — **gitignored**. `project_id`, `resource_id`, timestamps. Unique per clone.
109
+
110
+ Teammates who clone the repo run `stack doctor --fix` and Stack re-authenticates each service for them.
111
+
112
+ Full schema: [stack.ashlr.ai/docs/config](https://stack.ashlr.ai/docs/config).
113
+
114
+ ## Links
115
+
116
+ - Docs — [stack.ashlr.ai/docs](https://stack.ashlr.ai/docs)
117
+ - Quickstart — [stack.ashlr.ai/docs/quickstart](https://stack.ashlr.ai/docs/quickstart)
118
+ - Provider catalog — [stack.ashlr.ai/docs/providers](https://stack.ashlr.ai/docs/providers)
119
+ - Repo — [github.com/ashlrai/ashlr-stack](https://github.com/ashlrai/ashlr-stack)
120
+ - Issues — [github.com/ashlrai/ashlr-stack/issues](https://github.com/ashlrai/ashlr-stack/issues)
121
+
122
+ ## License
123
+
124
+ MIT. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@ashlr/stack",
3
+ "version": "0.1.1",
4
+ "description": "Ashlr Stack CLI — the control plane for your entire dev stack. One command to provision, wire, and operate every third-party service in your project. 23 providers, MCP-native.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Mason Wyatt",
8
+ "url": "https://github.com/ashlrai"
9
+ },
10
+ "homepage": "https://stack.ashlr.ai",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ashlrai/ashlr-stack.git",
14
+ "directory": "packages/cli"
15
+ },
16
+ "bugs": "https://github.com/ashlrai/ashlr-stack/issues",
17
+ "keywords": [
18
+ "ashlr",
19
+ "stack",
20
+ "cli",
21
+ "mcp",
22
+ "claude-code",
23
+ "phantom-secrets",
24
+ "supabase",
25
+ "neon",
26
+ "vercel",
27
+ "cloudflare",
28
+ "openai",
29
+ "anthropic",
30
+ "provisioning",
31
+ "devops",
32
+ "control-plane"
33
+ ],
34
+ "type": "module",
35
+ "bin": {
36
+ "stack": "./src/index.ts"
37
+ },
38
+ "files": [
39
+ "src/**/*",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "engines": {
48
+ "bun": ">=1.2.0",
49
+ "node": ">=20"
50
+ },
51
+ "scripts": {
52
+ "dev": "bun run src/index.ts",
53
+ "typecheck": "bunx tsc --noEmit"
54
+ },
55
+ "dependencies": {
56
+ "@ashlr/stack-core": "^0.1.1",
57
+ "@clack/prompts": "^0.8.2",
58
+ "citty": "^0.1.6",
59
+ "picocolors": "^1.1.1"
60
+ },
61
+ "devDependencies": {
62
+ "@types/bun": "latest",
63
+ "typescript": "^5.7.0"
64
+ }
65
+ }
@@ -0,0 +1,80 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ /**
9
+ * End-to-end test for `stack apply` — specifically the auto-init behaviour.
10
+ * The marketed golden path is `stack recommend --save && stack apply <id>`
11
+ * from a blank directory; that must not explode on a missing .stack.toml.
12
+ *
13
+ * We don't exercise the provider-provisioning side (real HTTP, OAuth, tokens
14
+ * required). `--noWire` + `--noRollback` isolate the init+pipeline behaviour.
15
+ */
16
+
17
+ const CLI_ENTRY = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
18
+
19
+ function runCli(args: string[], cwd?: string): { stdout: string; stderr: string; code: number } {
20
+ const result = spawnSync("bun", [CLI_ENTRY, ...args], {
21
+ encoding: "utf8",
22
+ cwd,
23
+ env: { ...process.env, NO_COLOR: "1", CI: "1" },
24
+ });
25
+ return {
26
+ stdout: result.stdout ?? "",
27
+ stderr: result.stderr ?? "",
28
+ code: result.status ?? 0,
29
+ };
30
+ }
31
+
32
+ const createdDirs: string[] = [];
33
+ afterEach(() => {
34
+ while (createdDirs.length > 0) {
35
+ const dir = createdDirs.pop();
36
+ if (dir) rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ });
39
+
40
+ function mkTmp(): string {
41
+ const dir = mkdtempSync(join(tmpdir(), "stack-apply-"));
42
+ createdDirs.push(dir);
43
+ return dir;
44
+ }
45
+
46
+ describe("stack apply", () => {
47
+ it("auto-inits .stack.toml when none exists, then runs against the recipe", () => {
48
+ const dir = mkTmp();
49
+
50
+ // Step 1: seed a recipe via `stack recommend --save`.
51
+ const rec = runCli(["recommend", "postgres database", "--save", "--json"], dir);
52
+ expect(rec.code).toBe(0);
53
+ const payload = JSON.parse(rec.stdout);
54
+ const recipeId: string = payload.recipe.id;
55
+ expect(existsSync(join(dir, ".stack.toml"))).toBe(false);
56
+
57
+ // Step 2: apply it from the same blank dir. Providers will fail at `stack
58
+ // add` because no OAuth/tokens are present, but `apply` must first create
59
+ // .stack.toml via the auto-init path.
60
+ const applied = runCli(["apply", recipeId, "--noWire", "--noRollback"], dir);
61
+
62
+ // Apply may exit non-zero because provider adds fail; that's expected.
63
+ // The invariant we care about: auto-init ran, so .stack.toml now exists.
64
+ expect(existsSync(join(dir, ".stack.toml"))).toBe(true);
65
+
66
+ // The banner breadcrumb should surface so the user understands the
67
+ // auto-init happened implicitly.
68
+ const out = applied.stdout + applied.stderr;
69
+ expect(out).toContain("auto-running");
70
+ });
71
+
72
+ it("rejects a missing recipe id with a clear error", () => {
73
+ const dir = mkTmp();
74
+ const { stdout, stderr, code } = runCli(["apply", "does-not-exist", "--noWire"], dir);
75
+ // Apply exits after pickRecipe fails; the surfaced error must name the
76
+ // recipe so the user knows what to fix.
77
+ expect(code).toBeGreaterThan(0);
78
+ expect(stdout + stderr).toContain("does-not-exist");
79
+ });
80
+ });
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ /**
9
+ * Smoke tests for `stack recommend`. Spawns the CLI the same way the MCP
10
+ * server will (bun subprocess) to cover the full JSON contract end-to-end.
11
+ */
12
+
13
+ const CLI_ENTRY = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
14
+
15
+ function runCli(args: string[], cwd?: string): { stdout: string; stderr: string; code: number } {
16
+ const result = spawnSync("bun", [CLI_ENTRY, ...args], {
17
+ encoding: "utf8",
18
+ cwd,
19
+ env: { ...process.env, NO_COLOR: "1", CI: "1" },
20
+ });
21
+ return {
22
+ stdout: result.stdout ?? "",
23
+ stderr: result.stderr ?? "",
24
+ code: result.status ?? 0,
25
+ };
26
+ }
27
+
28
+ const createdDirs: string[] = [];
29
+ afterEach(() => {
30
+ while (createdDirs.length > 0) {
31
+ const dir = createdDirs.pop();
32
+ if (dir) rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ function mkTmp(): string {
37
+ const dir = mkdtempSync(join(tmpdir(), "stack-recommend-"));
38
+ createdDirs.push(dir);
39
+ return dir;
40
+ }
41
+
42
+ describe("stack recommend --json", () => {
43
+ it("returns structured JSON with hits for a SaaS query", () => {
44
+ const { stdout, code } = runCli(["recommend", "B2B SaaS with auth and payments", "--json"]);
45
+ expect(code).toBe(0);
46
+ const payload = JSON.parse(stdout);
47
+ expect(payload.query).toBe("B2B SaaS with auth and payments");
48
+ expect(Array.isArray(payload.hits)).toBe(true);
49
+ expect(payload.hits.length).toBeGreaterThan(0);
50
+ const hitNames = payload.hits.map((h: { name: string }) => h.name);
51
+ expect(hitNames).toContain("stripe");
52
+ // clerk or supabase should cover the auth side
53
+ expect(hitNames.some((n: string) => ["clerk", "supabase"].includes(n))).toBe(true);
54
+ expect(typeof payload.guidance).toBe("string");
55
+ expect(payload.byCategory).toBeDefined();
56
+ });
57
+
58
+ it("narrows to a category filter", () => {
59
+ const { stdout, code } = runCli([
60
+ "recommend",
61
+ "edge compute",
62
+ "--category",
63
+ "Deploy",
64
+ "--json",
65
+ ]);
66
+ expect(code).toBe(0);
67
+ const payload = JSON.parse(stdout);
68
+ for (const hit of payload.hits) {
69
+ expect(hit.category).toBe("Deploy");
70
+ }
71
+ });
72
+
73
+ it("with --save persists a recipe under .stack/recipes/", () => {
74
+ const dir = mkTmp();
75
+ const { stdout, code } = runCli(["recommend", "postgres database", "--save", "--json"], dir);
76
+ expect(code).toBe(0);
77
+ const payload = JSON.parse(stdout);
78
+ expect(payload.recipe).toBeDefined();
79
+ expect(payload.recipe.id).toBeDefined();
80
+ expect(payload.recipe.path).toContain(".stack/recipes");
81
+
82
+ const recipesDir = join(dir, ".stack", "recipes");
83
+ const files = readdirSync(recipesDir);
84
+ expect(files.length).toBe(1);
85
+ expect(files[0]).toMatch(/\.toml$/);
86
+ expect(files[0]).toBe(`${payload.recipe.id}.toml`);
87
+ });
88
+
89
+ it("without --save does not create .stack/recipes", () => {
90
+ const dir = mkTmp();
91
+ const { code } = runCli(["recommend", "postgres database", "--json"], dir);
92
+ expect(code).toBe(0);
93
+ try {
94
+ const files = readdirSync(join(dir, ".stack", "recipes"));
95
+ expect(files.length).toBe(0);
96
+ } catch (err) {
97
+ // ENOENT is the expected happy path — no dir created.
98
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
99
+ }
100
+ });
101
+
102
+ it("emits an empty hits array with fallback guidance for a gibberish query", () => {
103
+ const { stdout, code } = runCli(["recommend", "xyzabcxyzabc nonsenseword", "--json"]);
104
+ expect(code).toBe(0);
105
+ const payload = JSON.parse(stdout);
106
+ expect(payload.hits).toEqual([]);
107
+ expect(payload.guidance).toContain("No strong matches");
108
+ });
109
+ });
@@ -0,0 +1,163 @@
1
+ import { defineCommand } from "citty";
2
+ import {
3
+ addService,
4
+ assertPhantomInstalled,
5
+ getProvider,
6
+ hasConfig,
7
+ listProviderNames,
8
+ } from "@ashlr/stack-core";
9
+ import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
10
+
11
+ export const addCommand = defineCommand({
12
+ meta: {
13
+ name: "add",
14
+ description: "Provision a service and wire its secrets + MCP entry.",
15
+ },
16
+ args: {
17
+ service: {
18
+ type: "positional",
19
+ required: false,
20
+ description: "Service name (e.g. supabase, neon, vercel). Omit for interactive picker.",
21
+ },
22
+ use: {
23
+ type: "string",
24
+ description: "Attach to an existing resource by id instead of creating a new one.",
25
+ },
26
+ region: {
27
+ type: "string",
28
+ description: "Region hint for providers that need one (e.g. us-east-1).",
29
+ },
30
+ dryRun: {
31
+ type: "boolean",
32
+ default: false,
33
+ description:
34
+ "Preview what would happen (no network calls, no vault writes, no MCP edits).",
35
+ },
36
+ },
37
+ async run({ args }) {
38
+ if (!hasConfig()) {
39
+ intro("stack add");
40
+ outroError("No .stack.toml found. Run `stack init` first.");
41
+ return;
42
+ }
43
+
44
+ const available = listProviderNames();
45
+
46
+ // Interactive picker when no service argument is provided.
47
+ let service = args.service ? String(args.service) : undefined;
48
+ if (!service) {
49
+ if (!process.stdout.isTTY) {
50
+ intro("stack add");
51
+ outroError("No service specified. Usage: stack add <service>");
52
+ return;
53
+ }
54
+ const grouped = await groupProvidersForPicker(available);
55
+ const picked = await prompts.select({
56
+ message: "Which service to add?",
57
+ options: grouped,
58
+ });
59
+ if (prompts.isCancel(picked)) {
60
+ intro("stack add");
61
+ outroError("Cancelled.");
62
+ return;
63
+ }
64
+ service = String(picked);
65
+ }
66
+
67
+ intro(`stack add ${service}${args.dryRun ? colors.dim(" (dry-run)") : ""}`);
68
+
69
+ if (!args.dryRun) {
70
+ try {
71
+ await assertPhantomInstalled();
72
+ } catch (err) {
73
+ outroError((err as Error).message);
74
+ return;
75
+ }
76
+ }
77
+
78
+ if (!available.includes(service)) {
79
+ // Group the catalog by category so the suggestion doesn't wrap into an
80
+ // unreadable one-line comma list on narrow terminals.
81
+ const byCat: Record<string, string[]> = {};
82
+ for (const name of available) {
83
+ try {
84
+ const p = await getProvider(name);
85
+ (byCat[p.category] ??= []).push(p.name);
86
+ } catch {
87
+ /* skip */
88
+ }
89
+ }
90
+ const lines = Object.entries(byCat)
91
+ .sort(([a], [b]) => a.localeCompare(b))
92
+ .map(([cat, names]) => ` ${colors.bold(cat.padEnd(10))} ${names.sort().join(" ")}`);
93
+ console.log();
94
+ console.log(` Unknown provider ${colors.red(`"${service}"`)}.`);
95
+ console.log();
96
+ console.log(colors.dim(" Available providers:"));
97
+ for (const line of lines) console.log(line);
98
+ console.log();
99
+ outroError("Pick one from the catalog above.");
100
+ return;
101
+ }
102
+
103
+ // Dry-run: describe what the flow would do without executing any step.
104
+ if (args.dryRun) {
105
+ const provider = await getProvider(service);
106
+ console.log();
107
+ console.log(
108
+ ` ${colors.bold(provider.displayName)} ${colors.dim(`(${provider.category} · ${provider.authKind})`)}`,
109
+ );
110
+ console.log(` ${colors.dim("1.")} login ${colors.dim("(prompt for PAT or run OAuth)")}`);
111
+ console.log(
112
+ ` ${colors.dim("2.")} provision ${args.use ? `attach to existing resource ${colors.dim(String(args.use))}` : colors.dim("create a new upstream resource")}`,
113
+ );
114
+ console.log(` ${colors.dim("3.")} materialize ${colors.dim("fetch credentials for the resource")}`);
115
+ console.log(` ${colors.dim("4.")} persist ${colors.dim("write secrets to Phantom + MCP + .stack.toml")}`);
116
+ console.log();
117
+ outro(colors.dim("dry-run complete — nothing written."));
118
+ return;
119
+ }
120
+
121
+ const spinner = prompts.spinner();
122
+ try {
123
+ spinner.start(`Wiring ${service}…`);
124
+ const result = await addService({
125
+ providerName: service,
126
+ existingResourceId: args.use ? String(args.use) : undefined,
127
+ hints: args.region ? { region: String(args.region) } : undefined,
128
+ interactive: process.stdout.isTTY === true,
129
+ log: (event) => {
130
+ spinner.stop();
131
+ logEvent(event);
132
+ spinner.start(`Wiring ${service}…`);
133
+ },
134
+ });
135
+ spinner.stop(
136
+ `${colors.green("●")} ${result.displayName} (${result.resourceId}) · ${result.secretCount} secrets${
137
+ result.mcpWired ? " · mcp wired" : ""
138
+ }`,
139
+ );
140
+ outro(`${colors.green("✓")} ${service} is live.`);
141
+ } catch (err) {
142
+ spinner.stop(colors.red("Failed."));
143
+ outroError((err as Error).message);
144
+ }
145
+ },
146
+ });
147
+
148
+ async function groupProvidersForPicker(names: string[]) {
149
+ const out: Array<{ label: string; value: string; hint?: string }> = [];
150
+ for (const name of names) {
151
+ try {
152
+ const p = await getProvider(name);
153
+ out.push({
154
+ value: p.name,
155
+ label: `${p.displayName} ${colors.dim(`· ${p.category}`)}`,
156
+ hint: p.authKind,
157
+ });
158
+ } catch {
159
+ /* skip */
160
+ }
161
+ }
162
+ return out.sort((a, b) => a.label.localeCompare(b.label));
163
+ }