@sprid/cli 0.1.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +14 -0
  3. package/README-post.md +615 -0
  4. package/README.md +249 -0
  5. package/SECURITY.md +18 -0
  6. package/bin.mjs +12 -0
  7. package/package.json +47 -0
  8. package/src/args.mjs +134 -0
  9. package/src/browser.mjs +28 -0
  10. package/src/cli.mjs +257 -0
  11. package/src/commands/auth.mjs +192 -0
  12. package/src/commands/completion.mjs +90 -0
  13. package/src/commands/connect.mjs +586 -0
  14. package/src/commands/docs.mjs +53 -0
  15. package/src/commands/family.mjs +96 -0
  16. package/src/commands/init.mjs +137 -0
  17. package/src/commands/local-tools.mjs +31 -0
  18. package/src/commands/marketing-review.mjs +106 -0
  19. package/src/commands/mcp.mjs +53 -0
  20. package/src/commands/misc.mjs +94 -0
  21. package/src/commands/pinterest.mjs +120 -0
  22. package/src/commands/plan.mjs +230 -0
  23. package/src/commands/post.mjs +41 -0
  24. package/src/commands/reviews.mjs +149 -0
  25. package/src/commands/setup.mjs +220 -0
  26. package/src/commands/status.mjs +217 -0
  27. package/src/commands/studio.mjs +69 -0
  28. package/src/commands/update.mjs +69 -0
  29. package/src/creds.mjs +126 -0
  30. package/src/docs/commands.mjs +152 -0
  31. package/src/docs/guides.generated.mjs +1178 -0
  32. package/src/docs/help.mjs +77 -0
  33. package/src/docs/index.d.mts +18 -0
  34. package/src/docs/index.mjs +45 -0
  35. package/src/docs/queries.d.mts +11 -0
  36. package/src/docs/queries.mjs +77 -0
  37. package/src/endpoint.mjs +17 -0
  38. package/src/evidence.mjs +15 -0
  39. package/src/format.mjs +71 -0
  40. package/src/http.mjs +117 -0
  41. package/src/pending.mjs +27 -0
  42. package/src/post/cli.mjs +2285 -0
  43. package/src/post/json-worker.mjs +12 -0
  44. package/src/post/preview-server.mjs +58 -0
  45. package/src/post/preview.mjs +660 -0
  46. package/src/post/recipes/screen.mjs +290 -0
  47. package/src/post/recipes/stills.mjs +146 -0
  48. package/src/post/rules/platform-rules.d.mts +27 -0
  49. package/src/post/rules/platform-rules.mjs +164 -0
  50. package/src/post/screen/captions.mjs +131 -0
  51. package/src/post/screen/compose.mjs +284 -0
  52. package/src/post/screen/input.mjs +163 -0
  53. package/src/post/screen/sim.mjs +443 -0
  54. package/src/post/screen/simkit.swift +328 -0
  55. package/src/profiles.mjs +61 -0
  56. package/src/release.ts +2 -0
  57. package/src/screenshots.ts +1 -0
  58. package/src/updates.mjs +152 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,257 @@
1
+ // sprid — the terminal face of one contract (REST = MCP = CLI), and the one
2
+ // trusted local process that reads key files so a secret never has to pass
3
+ // through an agent's conversation.
4
+ //
5
+ // Every command takes `--json`. Human output is short and aligned; the only
6
+ // symbols are ✓ ✗ –. `run(argv, io)` is the whole program: `io` exists so the
7
+ // tests can hand in a fake server, a fake browser and a clock that does not
8
+ // wait.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { hostname } from "node:os";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import { parseArgs, UsageError } from "./args.mjs";
16
+ import { readLocalApp } from "./profiles.mjs";
17
+ import { DEFAULT_API_URL, appUrlFor, resolveAuth } from "./creds.mjs";
18
+ import { makeClient, ApiError } from "./http.mjs";
19
+ import { openBrowser as realOpenBrowser } from "./browser.mjs";
20
+ import { C } from "./format.mjs";
21
+
22
+ import * as auth from "./commands/auth.mjs";
23
+ import * as status from "./commands/status.mjs";
24
+ import * as connect from "./commands/connect.mjs";
25
+ import * as init from "./commands/init.mjs";
26
+ import { setup } from "./commands/setup.mjs";
27
+ import * as misc from "./commands/misc.mjs";
28
+ import * as mcp from "./commands/mcp.mjs";
29
+ import * as reviews from "./commands/reviews.mjs";
30
+ import { marketingReview } from "./commands/marketing-review.mjs";
31
+ import { plan, research } from './commands/plan.mjs';
32
+ import { apps, family, insights, account } from "./commands/family.mjs";
33
+ import * as post from "./commands/post.mjs";
34
+ import { docs } from "./commands/docs.mjs";
35
+ import { screenshots, release } from './commands/local-tools.mjs';
36
+ import { pinterest } from './commands/pinterest.mjs';
37
+ import { media, capabilities } from './commands/studio.mjs';
38
+ import { getHelp, renderHelp, suggest } from "./docs/help.mjs";
39
+ import { completion } from "./commands/completion.mjs";
40
+
41
+ import { doctor, update, notifyUpdate } from './commands/update.mjs';
42
+
43
+ export const VERSION = readVersion();
44
+
45
+ function readVersion() {
46
+ try {
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
49
+ } catch {
50
+ return "0.0.0";
51
+ }
52
+ }
53
+
54
+ export const COMMANDS = {
55
+ plan, research,
56
+ media, capabilities, completion,
57
+ screenshots, release, doctor, update,
58
+ apps, family, insights, account,
59
+ docs,
60
+ login: auth.login,
61
+ logout: auth.logout,
62
+ whoami: auth.whoami,
63
+ use: auth.use,
64
+ status: status.status,
65
+ next: status.next,
66
+ queue: status.queue,
67
+ connect: connect.connect,
68
+ disconnect: connect.disconnect,
69
+ init: init.init,
70
+ setup,
71
+ open: misc.open,
72
+ invite: misc.invite,
73
+ upgrade: misc.upgrade,
74
+ mcp: mcp.mcp,
75
+ reviews: reviews.reviews,
76
+ "marketing-review": marketingReview,
77
+ post: post.post,
78
+ pinterest,
79
+ help: help,
80
+ };
81
+
82
+ export const HELP = renderHelp(getHelp(), VERSION);
83
+
84
+ /** See ctx.workspace(). Exported for `sprid use`. */
85
+ export async function pickWorkspace(ctx, ref) {
86
+ const list = await ctx.workspaces();
87
+ const hit = list.find((w) => w.slug === String(ref) || String(w.id) === String(ref));
88
+ if (!hit) {
89
+ throw new UsageError(
90
+ list.length
91
+ ? `No workspace "${ref}" for this token. It sees: ${list.map((w) => w.slug).join(", ")}.`
92
+ : `No workspace "${ref}": this token sees no workspaces.`,
93
+ );
94
+ }
95
+ return { id: hit.id, slug: hit.slug, name: hit.name, tier: hit.tier };
96
+ }
97
+
98
+ async function resolveWorkspace(ctx) {
99
+ if (ctx.flags.workspace && ctx.flags.workspace !== true) return pickWorkspace(ctx, ctx.flags.workspace);
100
+ const pinned = readLocalApp(ctx.cwd)?.data?.workspaceId;
101
+ if (pinned) return pickWorkspace(ctx, pinned);
102
+ const a = ctx.auth();
103
+ if (a.workspace?.id) return a.workspace;
104
+ const list = await ctx.workspaces();
105
+ if (list.length === 1) return { id: list[0].id, slug: list[0].slug, name: list[0].name, tier: list[0].tier };
106
+ if (list.length === 0) throw new ApiError(400, "This token belongs to no workspace.");
107
+ const local = readLocalApp(ctx.cwd)?.data?.workspace;
108
+ if (local) return pickWorkspace(ctx, local);
109
+ const rows = list.map((w) => ` ${w.slug.padEnd(Math.max(...list.map((x) => x.slug.length)) + 2)}${w.name} (id ${w.id})`).join("\n");
110
+ throw new UsageError(`This token sees ${list.length} workspaces and none is chosen:\n${rows}\n Pick one: sprid use <slug> (or -w <slug> for one command)`);
111
+ }
112
+
113
+ async function help(ctx) {
114
+ const reference = getHelp(ctx.helpPath ?? ctx.positionals);
115
+ ctx.json ? ctx.out({ version: VERSION, ...reference }) : ctx.print(renderHelp(reference, VERSION));
116
+ return 0;
117
+ }
118
+
119
+ /**
120
+ * @param {string[]} argv
121
+ * @param {object} io overrides for tests: env, cwd, stdout, stderr, openBrowser, sleep, fetch, stdin
122
+ * @returns {Promise<number>} exit code
123
+ */
124
+ export async function run(argv, io = {}) {
125
+ const env = io.env ?? process.env;
126
+ const write = io.stdout ?? ((s) => process.stdout.write(s));
127
+ const writeErr = io.stderr ?? ((s) => process.stderr.write(s));
128
+ let parsed;
129
+ try {
130
+ parsed = parseArgs(argv);
131
+ } catch (e) {
132
+ const options = argv.slice(0, argv.includes('--') ? argv.indexOf('--') : argv.length);
133
+ if (options.includes('--json') || options.includes('--json=true')) write(JSON.stringify({ ok: false, error: e.message, status: null }) + '\n');
134
+ else writeErr(` ✗ ${e.message}\n`);
135
+ return 2;
136
+ }
137
+ const { flags, positionals } = parsed;
138
+ const json = Boolean(flags.json);
139
+ let command = parsed.command;
140
+ const passthrough = ['post', 'media', 'screenshots', 'release'].includes(command);
141
+ if (flags.version && !passthrough) command = "version";
142
+ const nativeHelp = ['screenshots', 'release'].includes(command);
143
+ const helpPath = flags.help && !nativeHelp && command !== 'help' && command
144
+ ? [command, ...positionals.slice(0, 1)] : undefined;
145
+ if ((flags.help && !nativeHelp) || !command) command = "help";
146
+
147
+ const ctx = {
148
+ env,
149
+ cwd: io.cwd ?? process.cwd(),
150
+ flags,
151
+ positionals,
152
+ helpPath,
153
+ commandArgv: parsed.commandArgv,
154
+ json,
155
+ version: VERSION,
156
+ updateOptions: io.updateOptions,
157
+ hostname: io.hostname ?? hostname(),
158
+ openBrowser: flags["no-browser"] ? async () => false : io.openBrowser ?? realOpenBrowser,
159
+ sleep: io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
160
+ fetch: io.fetch ?? globalThis.fetch,
161
+ stdin: io.stdin ?? process.stdin,
162
+ /** Raw stdout, for the MCP adapter (no newline handling). */
163
+ rawWrite: write,
164
+ /** One line to the person. */
165
+ print: (s) => write(s.endsWith("\n") ? s : s + "\n"),
166
+ /** The JSON document, when --json. */
167
+ out: (obj) => write(JSON.stringify(obj, null, 2) + "\n"),
168
+ warn: (s) => writeErr(s.endsWith("\n") ? s : s + "\n"),
169
+ _auth: null,
170
+ auth() {
171
+ if (!this._auth) this._auth = resolveAuth(env, flags);
172
+ return this._auth;
173
+ },
174
+ _noticed: new Set(),
175
+ /** Server notices (the CLI version floor) are worth saying once, never once per request. */
176
+ notice(text) {
177
+ if (this._noticed.has(text)) return;
178
+ this._noticed.add(text);
179
+ this.warn(` ! ${text}`);
180
+ },
181
+ api() {
182
+ const a = this.auth();
183
+ return makeClient({ apiUrl: a.apiUrl, token: a.token, fetchImpl: this.fetch, userAgent: `sprid/${VERSION}`, version: VERSION, onNotice: text => this.notice(text) });
184
+ },
185
+ /** A client for a given base + token (null token for the public device-flow routes). */
186
+ clientFor(apiUrl, token) {
187
+ return makeClient({ apiUrl, token, fetchImpl: this.fetch, userAgent: `sprid/${VERSION}`, version: VERSION, onNotice: text => this.notice(text) });
188
+ },
189
+ appUrl() {
190
+ return appUrlFor(this.auth().apiUrl, env);
191
+ },
192
+ /** The app URL even when nobody is logged in (for help text). */
193
+ appUrlSafe() {
194
+ try {
195
+ return this.appUrl();
196
+ } catch {
197
+ return appUrlFor(env.SPRID_URL || DEFAULT_API_URL, env);
198
+ }
199
+ },
200
+ _me: null,
201
+ /** GET /api/me, once. */
202
+ async me() {
203
+ if (!this._me) this._me = await this.api().get("/api/me");
204
+ return this._me;
205
+ },
206
+ /** The workspaces this token can see: [{id, slug, name, tier}]. */
207
+ async workspaces() {
208
+ return (await this.me())?.user?.workspaces ?? [];
209
+ },
210
+ _workspace: undefined,
211
+ /**
212
+ * The workspace every request is about, as {id, slug, name}. Order:
213
+ * --workspace/-w (slug or id) > pinned workspaceId in .sprid/app.json >
214
+ * the one stored by login / `sprid use` >
215
+ * the only one the token sees > `workspace` in ./.sprid/app.json > a list
216
+ * and exit 2. The server's own 400 ("Pass ?workspaceId=") never reaches
217
+ * the person.
218
+ */
219
+ async workspace() {
220
+ if (this._workspace !== undefined) return this._workspace;
221
+ this._workspace = await resolveWorkspace(this);
222
+ return this._workspace;
223
+ },
224
+ async workspaceId() {
225
+ return (await this.workspace()).id;
226
+ },
227
+ };
228
+
229
+ if (command === "version") {
230
+ json ? ctx.out({ version: VERSION }) : ctx.print(VERSION);
231
+ return 0;
232
+ }
233
+
234
+ const fn = COMMANDS[command];
235
+ if (!fn) {
236
+ const match = suggest(command, Object.keys(COMMANDS));
237
+ const message = `Unknown command "${command}".${match ? ` Did you mean "${match}"?` : ''} Try sprid help.`;
238
+ if (json) ctx.out({ ok: false, error: message, status: null });
239
+ else ctx.warn(` ✗ ${message}`);
240
+ return 2;
241
+ }
242
+
243
+ try {
244
+ if (io.notifyUpdates && !flags.offline && ["status", "next"].includes(command)) await notifyUpdate(ctx).catch(() => {});
245
+ const code = await fn(ctx);
246
+ return code ?? 0;
247
+ } catch (e) {
248
+ const code = e && e.exitCode ? e.exitCode : 1;
249
+ const message = e && e.message ? e.message : String(e);
250
+ if (json) {
251
+ write(JSON.stringify({ ok: false, error: message, status: e?.status ?? null }) + "\n");
252
+ } else {
253
+ writeErr(` ${C.bad("✗")} ${message}\n`);
254
+ }
255
+ return code;
256
+ }
257
+ }
@@ -0,0 +1,192 @@
1
+ // login / logout / whoami. The device authorization grant: this process holds
2
+ // a long secret, the person types a short code into a signed-in browser, and
3
+ // the server hands over a personal access token exactly once.
4
+
5
+ import { DEFAULT_API_URL, deleteCredentials, credentialsPath, readCredentials, stripSlash, writeCredentials } from "../creds.mjs";
6
+ import { ApiError } from "../http.mjs";
7
+ import { C, OK } from "../format.mjs";
8
+ import { pickWorkspace } from "../cli.mjs";
9
+ import { pendingGrant } from "../pending.mjs";
10
+ import { assertCredentialDestination } from '../endpoint.mjs';
11
+
12
+ export async function login(ctx) {
13
+ const apiUrl = stripSlash(ctx.env.SPRID_URL || ctx.flags.api || DEFAULT_API_URL);
14
+ const api = ctx.clientFor(apiUrl, null);
15
+ const clientName = `sprid ${ctx.version} on ${ctx.hostname}`.slice(0, 80);
16
+
17
+ const pending = pendingGrant(ctx, `login:${apiUrl}:${ctx.cwd}`);
18
+ const previous = pending.read();
19
+ const start = previous ?? await api.post("/api/auth/device", { clientName });
20
+ const { deviceCode, userCode, verificationUrlComplete, verificationUrl, interval = 5, expiresIn = 900 } = start;
21
+ const openUrl = verificationUrlComplete || verificationUrl;
22
+ const expiresAt = previous?.expiresAt ?? Date.now() + expiresIn * 1000;
23
+ if (!previous) pending.write({ ...start, expiresAt });
24
+
25
+ if (ctx.json) {
26
+ // An agent driving this sees the code once, on stderr, so stdout stays one document.
27
+ ctx.warn(JSON.stringify({ userCode, verificationUrl: openUrl }));
28
+ } else {
29
+ ctx.print(` Open ${C.bold(openUrl)} and enter ${C.bold(userCode)}`);
30
+ }
31
+ const opened = previous ? false : await ctx.openBrowser(openUrl);
32
+ if (!ctx.json && opened) ctx.print(C.dim(" (opening browser…)"));
33
+
34
+ const deadline = expiresAt;
35
+ let waitMs = Math.max(1, Number(interval) || 5) * 1000;
36
+ let result = null;
37
+ while (Date.now() < deadline) {
38
+ await ctx.sleep(waitMs);
39
+ const { status, data } = await api.raw("POST", "/api/auth/device/token", { deviceCode });
40
+ if (status === 200 && data?.token) {
41
+ result = data;
42
+ break;
43
+ }
44
+ if (status === 428) {
45
+ if (data?.interval) waitMs = Math.max(1, Number(data.interval)) * 1000;
46
+ continue;
47
+ }
48
+ if (status === 429) {
49
+ waitMs += 5000;
50
+ continue;
51
+ }
52
+ const code = data?.error;
53
+ if (["access_denied", "expired_token", "invalid_grant"].includes(code)) pending.clear();
54
+ if (code === "access_denied") throw new ApiError(status, "Denied in the browser. Nothing was stored.");
55
+ if (code === "expired_token") throw new ApiError(status, "The code expired before it was approved. Run `sprid login` again.");
56
+ if (code === "invalid_grant") throw new ApiError(status, data?.message || "The device code was not accepted. Run `sprid login` again.");
57
+ throw new ApiError(status, data?.message || `Unexpected ${status} while waiting for approval.`);
58
+ }
59
+ if (!result) throw new ApiError(408, "The code expired before it was approved. Run `sprid login` again.");
60
+
61
+ const finalApi = stripSlash(result.apiUrl || apiUrl);
62
+ assertCredentialDestination(finalApi, apiUrl);
63
+ // Ask who we are so the file carries the email. Best effort: a failure here
64
+ // must not lose the token that was just minted.
65
+ let email = null;
66
+ try {
67
+ const me = await ctx.clientFor(finalApi, result.token).get("/api/me");
68
+ email = me?.user?.email ?? null;
69
+ } catch {
70
+ email = null;
71
+ }
72
+
73
+ const creds = {
74
+ apiUrl: finalApi,
75
+ token: result.token,
76
+ tokenId: result.tokenId ?? null,
77
+ workspace: result.workspace ?? null,
78
+ email,
79
+ createdAt: new Date().toISOString(),
80
+ };
81
+ const path = writeCredentials(creds, ctx.env);
82
+ pending.clear();
83
+
84
+ if (ctx.json) {
85
+ ctx.out({ ok: true, apiUrl: creds.apiUrl, tokenId: creds.tokenId, workspace: creds.workspace, email, credentials: path });
86
+ } else {
87
+ const ws = creds.workspace ? `workspace ${creds.workspace.slug}` : "no workspace";
88
+ const tier = creds.workspace?.tier ? ` · ${tierName(creds.workspace.tier)}` : "";
89
+ ctx.print(` ${C.ok(OK)} ${email ?? "signed in"} · ${ws}${tier}`);
90
+ ctx.print(C.dim(` Token stored at ${path} (0600). Revoke any time: \`sprid logout\` or Settings → Tokens.`));
91
+ ctx.print(" Next, in your app’s repo: sprid init, then sprid connect. Already set up? Run sprid status.");
92
+ }
93
+ return 0;
94
+ }
95
+
96
+ export async function logout(ctx) {
97
+ const file = readCredentials(ctx.env);
98
+ let revoked = false;
99
+ if (file?.tokenId) {
100
+ try {
101
+ // raw(): a token that is already gone (401/404) is not an error here.
102
+ const { status } = await ctx.clientFor(file.apiUrl || DEFAULT_API_URL, file.token).raw("DELETE", `/api/tokens/${file.tokenId}`);
103
+ revoked = status >= 200 && status < 300;
104
+ } catch {
105
+ revoked = false;
106
+ }
107
+ }
108
+ const path = deleteCredentials(ctx.env);
109
+ if (ctx.json) {
110
+ ctx.out({ ok: true, revoked, removed: Boolean(file), credentials: path });
111
+ } else if (!file) {
112
+ ctx.print(` Nothing stored at ${path}. Already logged out.`);
113
+ } else {
114
+ ctx.print(` ${C.ok(OK)} Logged out. ${revoked ? "Token revoked on the server, local file" : "Local file"} removed.`);
115
+ }
116
+ return 0;
117
+ }
118
+
119
+ export async function whoami(ctx) {
120
+ const a = ctx.auth();
121
+ const me = await ctx.me();
122
+ const user = me?.user ?? {};
123
+ const workspaces = user.workspaces ?? [];
124
+ // The current one, if it can be known without asking; never exit 2 from whoami.
125
+ const current = await ctx.workspace().catch(() => null);
126
+ const source = a.source === "env" ? "SPRID_PAT" : credentialsPath(ctx.env);
127
+ if (ctx.json) {
128
+ ctx.out({
129
+ email: user.email ?? a.email ?? null,
130
+ userId: user.id ?? null,
131
+ workspace: current,
132
+ workspaces,
133
+ apiUrl: a.apiUrl,
134
+ tokenSource: source,
135
+ tokenId: a.tokenId,
136
+ });
137
+ return 0;
138
+ }
139
+ const wsLine = current
140
+ ? `${current.slug}${current.tier ? ` (${tierName(current.tier)})` : ""}`
141
+ : workspaces.length
142
+ ? "none chosen"
143
+ : "no workspace";
144
+ ctx.print(` ${user.email ?? a.email ?? "(unknown)"} · workspace ${wsLine}`);
145
+ ctx.print(C.dim(` ${a.apiUrl} · token from ${source}`));
146
+ if (workspaces.length > 1) {
147
+ ctx.print(workspaceList(workspaces, current));
148
+ if (!current) ctx.print(C.dim(" Pick one: sprid use <slug>"));
149
+ }
150
+ return 0;
151
+ }
152
+
153
+ export function workspaceList(workspaces, current) {
154
+ const w = Math.max(...workspaces.map((x) => x.slug.length)) + 2;
155
+ return workspaces
156
+ .map((x) => ` ${x.id === current?.id ? "*" : " "} ${x.slug.padEnd(w)}${x.name}${x.tier ? C.dim(` ${tierName(x.tier)}`) : ""} ${C.dim(`id ${x.id}`)}`)
157
+ .join("\n");
158
+ }
159
+
160
+ /**
161
+ * `sprid use <slug|id>` remembers the workspace in the credentials file. With
162
+ * SPRID_PAT and no file, the file is written with only `workspace`; token
163
+ * resolution stays env > file, so nothing about auth changes.
164
+ */
165
+ export async function use(ctx) {
166
+ const [ref] = ctx.positionals;
167
+ const workspaces = await ctx.workspaces();
168
+ if (!ref) {
169
+ const current = await ctx.workspace().catch(() => null);
170
+ if (ctx.json) {
171
+ ctx.out({ current, workspaces });
172
+ return 0;
173
+ }
174
+ if (!workspaces.length) {
175
+ ctx.print(" This token sees no workspaces.");
176
+ return 0;
177
+ }
178
+ ctx.print(workspaceList(workspaces, current));
179
+ if (!current) ctx.print(C.dim(" Pick one: sprid use <slug>"));
180
+ return 0;
181
+ }
182
+ const picked = await pickWorkspace(ctx, ref);
183
+ const file = readCredentials(ctx.env) ?? {};
184
+ const path = writeCredentials({ ...file, workspace: picked }, ctx.env);
185
+ if (ctx.json) ctx.out({ ok: true, workspace: picked, credentials: path });
186
+ else ctx.print(` ${C.ok(OK)} Using ${picked.name} (${picked.slug})`);
187
+ return 0;
188
+ }
189
+
190
+ export function tierName(tier) {
191
+ return { starter: "Pulse", growth: "Push", dev: "Push", founding: "Founding", lifetime: "Founding", founder: "Founder" }[tier] ?? tier;
192
+ }
@@ -0,0 +1,90 @@
1
+ import { commandNames, commandEntries, subcommands, HELP_TOPICS } from '../docs/help.mjs';
2
+ import { getDocumentation } from '../docs/index.mjs';
3
+ import { VALUE_FLAGS, UsageError } from '../args.mjs';
4
+
5
+ export function completionWords() {
6
+ return Object.fromEntries(commandNames().map(command => {
7
+ const entries = commandEntries(command);
8
+ const words = entries.flatMap(entry => [...subcommands(entry), ...(entry.usage.match(/--[a-z][a-z-]*/g) ?? [])]);
9
+ if (command === 'docs') words.push(...getDocumentation().guides.map(g => g.id));
10
+ if (command === 'help') words.push(...commandNames(), ...Object.keys(HELP_TOPICS));
11
+ return [command, [...new Set([...words, '--help', '--json', '--workspace'])].sort()];
12
+ }));
13
+ }
14
+
15
+ // Only catalog-owned words enter generated shell code. No user values or API calls.
16
+ export function completionScript(shell) {
17
+ const words = completionWords();
18
+ const names = Object.keys(words).join(' ');
19
+ const valueFlags = new Set(VALUE_FLAGS);
20
+ for (const command of commandNames()) for (const entry of commandEntries(command)) {
21
+ for (const [, flag, value] of entry.usage.matchAll(/--([a-z-]+)\s+(\S+)/g)) {
22
+ if (!value.startsWith('-') && !value.startsWith('[--')) valueFlags.add(flag);
23
+ }
24
+ }
25
+ const valueOptions = [...valueFlags].map(flag => `--${flag}`).concat('-w');
26
+ if (shell === 'bash') return `# Source with: source <(sprid completion bash)
27
+ _sprid_complete() {
28
+ local cur prev command word skip=0 i options
29
+ cur="\${COMP_WORDS[COMP_CWORD]}"
30
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
31
+ COMPREPLY=()
32
+ case "$prev" in ${valueOptions.join('|')}) return ;; esac
33
+ for ((i=1; i<COMP_CWORD; i++)); do
34
+ word="\${COMP_WORDS[i]}"
35
+ if ((skip)); then skip=0; continue; fi
36
+ case "$word" in
37
+ --) return ;;
38
+ ${valueOptions.join('|')}) skip=1 ;;
39
+ -*) ;;
40
+ *) command="$word"; break ;;
41
+ esac
42
+ done
43
+ options='${names} --help --version --json'
44
+ case "$command" in
45
+ ${Object.entries(words).map(([name, options]) => ` ${name}) options='${options.join(' ')}' ;;`).join('\n')}
46
+ esac
47
+ COMPREPLY=( $(compgen -W "$options" -- "$cur") )
48
+ }
49
+ complete -o default -F _sprid_complete sprid
50
+ `;
51
+ if (shell === 'zsh') return `# Run compinit first, then: source <(sprid completion zsh)
52
+ _sprid_complete() {
53
+ local command word skip=0 i
54
+ local -a candidates
55
+ case "$words[CURRENT-1]" in ${valueOptions.join('|')}) _files; return ;; esac
56
+ for ((i=2; i<CURRENT; i++)); do
57
+ word="$words[i]"
58
+ if ((skip)); then skip=0; continue; fi
59
+ case "$word" in
60
+ --) _files; return ;;
61
+ ${valueOptions.join('|')}) skip=1 ;;
62
+ -*) ;;
63
+ *) command="$word"; break ;;
64
+ esac
65
+ done
66
+ candidates=(${names} --help --version --json)
67
+ case "$command" in
68
+ ${Object.entries(words).map(([name, options]) => ` ${name}) candidates=(${options.join(' ')}) ;;`).join('\n')}
69
+ esac
70
+ compadd -- $candidates
71
+ _files
72
+ }
73
+ compdef _sprid_complete sprid
74
+ `;
75
+ if (shell === 'fish') return `# Load with: sprid completion fish | source
76
+ complete -c sprid -n '__fish_use_subcommand' -a '${names}'
77
+ ${Object.entries(words).flatMap(([name, options]) => options.map(option => option.startsWith('--')
78
+ ? `complete -c sprid -n '__fish_seen_subcommand_from ${name}' -l ${option.slice(2)}${valueFlags.has(option.slice(2)) ? ' -r' : ''}`
79
+ : `complete -c sprid -n '__fish_seen_subcommand_from ${name}' -a '${option}'`)).join('\n')}
80
+ `;
81
+ throw new UsageError('Use sprid completion bash, zsh or fish.');
82
+ }
83
+
84
+ export async function completion(ctx) {
85
+ if (ctx.positionals.length !== 1) throw new UsageError('Use sprid completion bash, zsh or fish.');
86
+ const shell = ctx.positionals[0];
87
+ const script = completionScript(shell);
88
+ ctx.json ? ctx.out({ shell, script }) : ctx.print(script);
89
+ return 0;
90
+ }