@topolo/cli 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.
Files changed (3) hide show
  1. package/README.md +131 -0
  2. package/dist/index.js +486 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # TopoloCli
2
+
3
+ Command-line interface for the Topolo platform. Designed for both humans and
4
+ third-party agents (Claude Code, Codex, Cursor, etc.) that prefer to interact
5
+ via `stdin`/`stdout`.
6
+
7
+ ## Phase 1 scope
8
+
9
+ - API-key authentication (long-lived, service-scoped).
10
+ - Access-token authentication (short-lived, cross-service — used for dev/testing).
11
+ - Commands: `auth login/logout/status`, `whoami`, `crm contacts:list`, `crm contacts:get`, generic `api` passthrough.
12
+ - OAuth2 authorization-code + device flows will arrive in Phase 2; until then, `auth login` expects a paste-in API key minted at the Topolo Developers console.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install -g @topolo/cli
18
+ # or run ad-hoc:
19
+ npx @topolo/cli whoami
20
+ ```
21
+
22
+ ## Configure
23
+
24
+ Credentials come from (in priority order):
25
+
26
+ 1. `TOPOLO_API_KEY` env var
27
+ 2. `TOPOLO_ACCESS_TOKEN` env var
28
+ 3. Config file (`$XDG_CONFIG_HOME/topolo/config.json`, mode `0600`)
29
+
30
+ For interactive use:
31
+
32
+ ```bash
33
+ topolo auth login # prompts for API key
34
+ topolo auth status # shows which source is active
35
+ topolo auth logout # clears the stored credential
36
+ ```
37
+
38
+ ## Cross-org isolation
39
+
40
+ The CLI **never accepts an `orgId` argument**. Every command is scoped to the
41
+ organization embedded in the credential. This is enforced in two places:
42
+
43
+ 1. The SDK does not expose any `orgId` parameter on any public method.
44
+ 2. Each Topolo backend app re-derives `orgId` from the credential on every request and binds it to SQL query filters.
45
+
46
+ There is no code path in the CLI that could ever let you read or mutate another
47
+ organization's data.
48
+
49
+ ## Audit headers
50
+
51
+ Every outbound request carries:
52
+
53
+ - `X-Topolo-Client: topolo-cli/<version>`
54
+ - `X-Topolo-Agent: <label>` (if `TOPOLO_AGENT_NAME` is set)
55
+ - `X-Topolo-Request-Id: <uuid>`
56
+
57
+ The platform logs these for traceability.
58
+
59
+ ## Write-action gating
60
+
61
+ The SDK defaults to requiring an explicit `--confirm` flag on any mutating
62
+ request. The generic `topolo api` passthrough enforces this too:
63
+
64
+ ```bash
65
+ topolo api crm POST /api/contacts --data '{"email":"a@b.co"}' --confirm
66
+ ```
67
+
68
+ ## Commands
69
+
70
+ ### `topolo whoami`
71
+
72
+ Prints the user, organization, and granted scopes.
73
+
74
+ ```bash
75
+ topolo whoami # pretty
76
+ topolo whoami --json # machine-readable
77
+ ```
78
+
79
+ ### `topolo crm contacts:list`
80
+
81
+ ```bash
82
+ topolo crm contacts:list
83
+ topolo crm contacts:list -q "acme" -p 2 -s 50 --json
84
+ ```
85
+
86
+ ### `topolo crm contacts:get <id>`
87
+
88
+ ```bash
89
+ topolo crm contacts:get contact_abc123
90
+ ```
91
+
92
+ ### `topolo api <service> <method> <path>`
93
+
94
+ Escape hatch for endpoints without typed modules yet.
95
+
96
+ ```bash
97
+ topolo api auth GET /api/me
98
+ topolo api crm POST /api/contacts --data '{"first":"Jane"}' --confirm
99
+ ```
100
+
101
+ ## Output modes
102
+
103
+ - Pretty when stdout is a TTY.
104
+ - JSON when piped or redirected.
105
+ - Force either with `--json` / `--no-json`.
106
+
107
+ ## Exit codes
108
+
109
+ | Code | Meaning |
110
+ | ---- | ----------------------- |
111
+ | 0 | Success |
112
+ | 1 | Generic error |
113
+ | 2 | Auth error |
114
+ | 3 | Permission denied |
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ cd TopoloCli
120
+ npm install
121
+ npm run build
122
+ node dist/index.js whoami
123
+ ```
124
+
125
+ ## Phase 2 (planned)
126
+
127
+ - `topolo auth login` replaces the paste-API-key flow with an OAuth 2.1 device
128
+ authorization grant issued by TopoloAuth.
129
+ - Refresh-token storage + automatic renewal.
130
+ - OAuth client registration lives in TopoloDevelopers — consent UI will follow
131
+ the existing `@topolo/design-tokens` + `@topolo/ui-kit` patterns.
package/dist/index.js ADDED
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/auth.ts
7
+ import { spawn } from "child_process";
8
+ import { platform as platform2 } from "os";
9
+ import { createInterface } from "readline/promises";
10
+ import { stdin as input } from "process";
11
+ import { TopoloOAuth, TopoloAuthError } from "@topolo/sdk";
12
+
13
+ // src/config.ts
14
+ import { mkdir, readFile, writeFile, chmod } from "fs/promises";
15
+ import { existsSync } from "fs";
16
+ import { homedir, platform } from "os";
17
+ import { dirname, join } from "path";
18
+ function configPath() {
19
+ const override = process.env.TOPOLO_CONFIG_PATH;
20
+ if (override) return override;
21
+ const base = process.env.XDG_CONFIG_HOME || (platform() === "win32" ? join(homedir(), "AppData", "Roaming") : join(homedir(), ".config"));
22
+ return join(base, "topolo", "config.json");
23
+ }
24
+ async function readConfig() {
25
+ const path = configPath();
26
+ if (!existsSync(path)) return {};
27
+ try {
28
+ const raw = await readFile(path, "utf8");
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+ async function writeConfig(config) {
35
+ const path = configPath();
36
+ await mkdir(dirname(path), { recursive: true });
37
+ await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
38
+ if (platform() !== "win32") {
39
+ try {
40
+ await chmod(path, 384);
41
+ } catch {
42
+ }
43
+ }
44
+ }
45
+ async function resolveCredential() {
46
+ const apiKey = process.env.TOPOLO_API_KEY;
47
+ if (apiKey) return { kind: "api_key", apiKey };
48
+ const accessToken = process.env.TOPOLO_ACCESS_TOKEN;
49
+ if (accessToken) return { kind: "access_token", accessToken };
50
+ const config = await readConfig();
51
+ return config.credential ?? null;
52
+ }
53
+ function resolveAgentName(configAgentName) {
54
+ return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
55
+ }
56
+ function resolveServiceUrls(configUrls) {
57
+ const merged = { ...configUrls ?? {} };
58
+ for (const [k, v] of Object.entries(process.env)) {
59
+ if (k.startsWith("TOPOLO_SERVICE_URL_") && v) {
60
+ merged[k.slice("TOPOLO_SERVICE_URL_".length).toLowerCase()] = v;
61
+ }
62
+ }
63
+ return Object.keys(merged).length > 0 ? merged : void 0;
64
+ }
65
+
66
+ // src/output.ts
67
+ function resolveOutputMode(override) {
68
+ if (override === true) return "json";
69
+ if (override === false) return "pretty";
70
+ return process.stdout.isTTY ? "pretty" : "json";
71
+ }
72
+ function emit(data, mode, pretty) {
73
+ if (mode === "json") {
74
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
75
+ } else {
76
+ process.stdout.write(pretty() + "\n");
77
+ }
78
+ }
79
+ function fail(err, mode) {
80
+ const payload = normalizeError(err);
81
+ if (mode === "json") {
82
+ process.stderr.write(JSON.stringify({ error: payload }, null, 2) + "\n");
83
+ } else {
84
+ process.stderr.write(`Error: ${payload.message}
85
+ `);
86
+ if (payload.code) process.stderr.write(`Code: ${payload.code}
87
+ `);
88
+ if (payload.hint) process.stderr.write(`Hint: ${payload.hint}
89
+ `);
90
+ }
91
+ process.exit(payload.exitCode);
92
+ }
93
+ function normalizeError(err) {
94
+ if (err instanceof Error) {
95
+ const code = err.code;
96
+ const hintMap = {
97
+ auth_error: "Run `topolo auth login` or set TOPOLO_API_KEY.",
98
+ permission_denied: "Check your API key scopes or role assignments in TopoloDevelopers."
99
+ };
100
+ return {
101
+ message: err.message,
102
+ code,
103
+ hint: code ? hintMap[code] : void 0,
104
+ exitCode: code === "auth_error" ? 2 : code === "permission_denied" ? 3 : 1
105
+ };
106
+ }
107
+ return { message: String(err), exitCode: 1 };
108
+ }
109
+
110
+ // src/commands/auth.ts
111
+ var API_KEY_ISSUANCE_URL = "https://developers.topolo.app/credentials";
112
+ var OAUTH_CLIENT_REGISTRATION_URL = "https://developers.topolo.app/developers/oauth-clients";
113
+ function registerAuthCommands(program2) {
114
+ const auth = program2.command("auth").description("Manage Topolo credentials");
115
+ auth.command("login").description("Authenticate with Topolo via OAuth device flow (default) or store an API key").option("--api-key <key>", "Store an API key directly, skipping the OAuth flow").option("--client-id <id>", "OAuth client_id (or set TOPOLO_CLI_CLIENT_ID)").option("--scope <scope>", "Scopes to request (space-separated)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(
116
+ async (opts) => {
117
+ const mode = resolveOutputMode(opts.json);
118
+ try {
119
+ if (opts.apiKey) {
120
+ await storeApiKey(opts.apiKey.trim(), mode);
121
+ return;
122
+ }
123
+ const clientId = opts.clientId ?? process.env.TOPOLO_CLI_CLIENT_ID;
124
+ if (!clientId) {
125
+ if (!input.isTTY) {
126
+ throw new Error(
127
+ `No OAuth client_id configured and stdin is not a TTY. Either register an OAuth client at ${OAUTH_CLIENT_REGISTRATION_URL} and export TOPOLO_CLI_CLIENT_ID, or pass --api-key <key> (mint one at ${API_KEY_ISSUANCE_URL}).`
128
+ );
129
+ }
130
+ process.stderr.write(
131
+ `No OAuth client_id configured. Falling back to API key.
132
+ Visit ${API_KEY_ISSUANCE_URL} to mint one, or configure an OAuth client at ${OAUTH_CLIENT_REGISTRATION_URL} and set TOPOLO_CLI_CLIENT_ID.
133
+ `
134
+ );
135
+ const rl = createInterface({ input, output: process.stderr });
136
+ const apiKey = (await rl.question("Paste API key: ")).trim();
137
+ rl.close();
138
+ if (!apiKey) throw new Error("API key is required");
139
+ await storeApiKey(apiKey, mode);
140
+ return;
141
+ }
142
+ await runDeviceFlow({ clientId, scope: opts.scope, mode });
143
+ } catch (err) {
144
+ fail(err, mode);
145
+ }
146
+ }
147
+ );
148
+ auth.command("logout").description("Remove stored credentials from this machine").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
149
+ const mode = resolveOutputMode(opts.json);
150
+ try {
151
+ const config = await readConfig();
152
+ delete config.credential;
153
+ await writeConfig(config);
154
+ emit({ ok: true }, mode, () => "Logged out.");
155
+ } catch (err) {
156
+ fail(err, mode);
157
+ }
158
+ });
159
+ auth.command("status").description("Show which credential source is in use (env vs config file)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
160
+ const mode = resolveOutputMode(opts.json);
161
+ try {
162
+ const source = process.env.TOPOLO_API_KEY ? "env:TOPOLO_API_KEY" : process.env.TOPOLO_ACCESS_TOKEN ? "env:TOPOLO_ACCESS_TOKEN" : (await readConfig()).credential ? "config-file" : "none";
163
+ emit({ source }, mode, () => `Credential source: ${source}`);
164
+ } catch (err) {
165
+ fail(err, mode);
166
+ }
167
+ });
168
+ }
169
+ async function storeApiKey(apiKey, mode) {
170
+ if (!apiKey) throw new Error("API key is required");
171
+ const config = await readConfig();
172
+ config.credential = { kind: "api_key", apiKey };
173
+ await writeConfig(config);
174
+ emit(
175
+ { ok: true, credential: { kind: "api_key" } },
176
+ mode,
177
+ () => "Stored API key. Run `topolo whoami` to verify."
178
+ );
179
+ }
180
+ async function runDeviceFlow(options) {
181
+ const config = await readConfig();
182
+ const oauth = new TopoloOAuth({
183
+ serviceUrls: resolveServiceUrls(config.serviceUrls)
184
+ });
185
+ const start = await oauth.requestDeviceCode({
186
+ clientId: options.clientId,
187
+ ...options.scope ? { scope: options.scope } : {}
188
+ });
189
+ const verifyUrl = start.verification_uri_complete ?? start.verification_uri;
190
+ const expiresMin = Math.round(start.expires_in / 60);
191
+ const opened = options.mode === "pretty" ? tryOpenBrowser(verifyUrl) : false;
192
+ if (options.mode === "pretty") {
193
+ process.stderr.write(
194
+ `
195
+ Sign in to Topolo
196
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
197
+
198
+ ` + (opened ? ` Opening your browser to approve...
199
+ ` : ` Open this link in your browser:
200
+ `) + ` ${verifyUrl}
201
+
202
+ The page will show this code \u2014 confirm it matches:
203
+
204
+ ${start.user_code}
205
+
206
+ Waiting for approval (expires in ${expiresMin} min)`
207
+ );
208
+ }
209
+ const spinner = options.mode === "pretty" ? startSpinner() : null;
210
+ let token;
211
+ try {
212
+ token = await pollUntilApproved({
213
+ oauth,
214
+ clientId: options.clientId,
215
+ deviceCode: start.device_code,
216
+ intervalSeconds: start.interval,
217
+ expiresAtMs: Date.now() + start.expires_in * 1e3
218
+ });
219
+ } finally {
220
+ spinner?.stop();
221
+ }
222
+ const updated = await readConfig();
223
+ updated.credential = {
224
+ kind: "access_token",
225
+ accessToken: token.access_token,
226
+ ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
227
+ expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
228
+ };
229
+ await writeConfig(updated);
230
+ emit(
231
+ {
232
+ ok: true,
233
+ credential: {
234
+ kind: "access_token",
235
+ expiresIn: token.expires_in,
236
+ scope: token.scope
237
+ }
238
+ },
239
+ options.mode,
240
+ () => "\n \u2713 Signed in. Run `topolo whoami` to see your identity."
241
+ );
242
+ }
243
+ function tryOpenBrowser(url) {
244
+ try {
245
+ const os = platform2();
246
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
247
+ const args = os === "win32" ? ["", url] : [url];
248
+ const child = spawn(cmd, args, {
249
+ detached: true,
250
+ stdio: "ignore",
251
+ ...os === "win32" ? { shell: true } : {}
252
+ });
253
+ child.unref();
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+ function startSpinner() {
260
+ if (!process.stderr.isTTY) return null;
261
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
262
+ let i = 0;
263
+ process.stderr.write(" ");
264
+ const timer = setInterval(() => {
265
+ process.stderr.write(`\b${frames[i++ % frames.length]}`);
266
+ }, 80);
267
+ return {
268
+ stop: () => {
269
+ clearInterval(timer);
270
+ process.stderr.write("\b \n");
271
+ }
272
+ };
273
+ }
274
+ async function pollUntilApproved(params) {
275
+ let interval = Math.max(params.intervalSeconds, 1);
276
+ while (Date.now() < params.expiresAtMs) {
277
+ await sleep(interval * 1e3);
278
+ try {
279
+ const token = await params.oauth.pollDeviceToken({
280
+ clientId: params.clientId,
281
+ deviceCode: params.deviceCode
282
+ });
283
+ return token;
284
+ } catch (err) {
285
+ if (err instanceof TopoloAuthError) {
286
+ if (err.code === "authorization_pending") continue;
287
+ if (err.code === "slow_down") {
288
+ interval += 2;
289
+ continue;
290
+ }
291
+ if (err.code === "access_denied") {
292
+ throw new Error("Authorization was denied.");
293
+ }
294
+ if (err.code === "expired_token") {
295
+ throw new Error("Device code expired. Run `topolo auth login` again.");
296
+ }
297
+ }
298
+ throw err;
299
+ }
300
+ }
301
+ throw new Error("Device code expired. Run `topolo auth login` again.");
302
+ }
303
+ function sleep(ms) {
304
+ return new Promise((resolve) => setTimeout(resolve, ms));
305
+ }
306
+
307
+ // src/client.ts
308
+ import { createTopolo } from "@topolo/sdk";
309
+
310
+ // src/version.ts
311
+ var CLI_VERSION = "0.1.0";
312
+
313
+ // src/client.ts
314
+ async function buildClient(options) {
315
+ const credential = await resolveCredential();
316
+ if (!credential) return null;
317
+ const config = await readConfig();
318
+ const serviceUrls = resolveServiceUrls(config.serviceUrls);
319
+ return createTopolo({
320
+ credential,
321
+ agent: {
322
+ clientName: "topolo-cli",
323
+ clientVersion: CLI_VERSION,
324
+ agentName: resolveAgentName(config.agentName)
325
+ },
326
+ serviceUrls,
327
+ requireConfirmForWrites: options?.requireConfirmForWrites
328
+ });
329
+ }
330
+
331
+ // src/commands/whoami.ts
332
+ function registerWhoamiCommand(program2) {
333
+ program2.command("whoami").description("Show the user, organization, and scopes attached to the current credential").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
334
+ const mode = resolveOutputMode(opts.json);
335
+ try {
336
+ const topolo = await buildClient();
337
+ if (!topolo) {
338
+ throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
339
+ }
340
+ const info = await topolo.identity.whoami();
341
+ emit(info, mode, () => formatPretty(info));
342
+ } catch (err) {
343
+ fail(err, mode);
344
+ }
345
+ });
346
+ }
347
+ function formatPretty(info) {
348
+ const lines = [];
349
+ lines.push(`User: ${info.user.name ?? "(unnamed)"} <${info.user.email}>`);
350
+ lines.push(`Role: ${info.user.role}`);
351
+ lines.push(
352
+ `Organization: ${info.organization ? `${info.organization.name} (${info.organization.slug})` : "(none)"}`
353
+ );
354
+ lines.push(`Credential: ${info.kind}`);
355
+ lines.push(`Scopes: ${info.user.permissions.length === 0 ? "(none)" : ""}`);
356
+ for (const p of info.user.permissions) lines.push(` - ${p}`);
357
+ return lines.join("\n");
358
+ }
359
+
360
+ // src/commands/crm.ts
361
+ function registerCrmCommands(program2) {
362
+ const crm = program2.command("crm").description("TopoloCRM operations");
363
+ crm.command("contacts:list").description("List contacts in the authenticated organization").option("-q, --query <text>", "Full-text search").option("-p, --page <n>", "Page number (1-indexed)", (v) => parseInt(v, 10), 1).option("-s, --page-size <n>", "Page size", (v) => parseInt(v, 10), 25).option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
364
+ const mode = resolveOutputMode(opts.json);
365
+ try {
366
+ const topolo = await buildClient();
367
+ if (!topolo) {
368
+ throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
369
+ }
370
+ const result = await topolo.crm.listContacts({
371
+ q: opts.query,
372
+ page: opts.page,
373
+ pageSize: opts.pageSize
374
+ });
375
+ emit(result, mode, () => formatContactsTable(result.contacts, result.total, result.page));
376
+ } catch (err) {
377
+ fail(err, mode);
378
+ }
379
+ });
380
+ crm.command("contacts:get <id>").description("Fetch a single contact by id").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (id, opts) => {
381
+ const mode = resolveOutputMode(opts.json);
382
+ try {
383
+ const topolo = await buildClient();
384
+ if (!topolo) {
385
+ throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
386
+ }
387
+ const contact = await topolo.crm.getContact(id);
388
+ if (!contact) throw new Error(`Contact not found: ${id}`);
389
+ emit(contact, mode, () => formatContact(contact));
390
+ } catch (err) {
391
+ fail(err, mode);
392
+ }
393
+ });
394
+ }
395
+ function formatContactsTable(rows, total, page) {
396
+ if (rows.length === 0) return "No contacts found.";
397
+ const lines = [];
398
+ lines.push(`Showing ${rows.length} of ${total} contacts (page ${page})`);
399
+ lines.push("");
400
+ for (const row of rows) {
401
+ const name = [row.firstName, row.lastName].filter(Boolean).join(" ") || "(no name)";
402
+ const parts = [name];
403
+ if (row.email) parts.push(row.email);
404
+ if (row.company) parts.push(`@ ${row.company}`);
405
+ if (row.leadStatus) parts.push(`[${row.leadStatus}]`);
406
+ lines.push(` ${row.id} ${parts.join(" \xB7 ")}`);
407
+ }
408
+ return lines.join("\n");
409
+ }
410
+ function formatContact(c) {
411
+ const name = [c.firstName, c.lastName].filter(Boolean).join(" ") || "(no name)";
412
+ return [
413
+ `ID: ${c.id}`,
414
+ `Name: ${name}`,
415
+ `Email: ${c.email ?? "-"}`,
416
+ `Phone: ${c.phone ?? "-"}`,
417
+ `Company: ${c.company ?? "-"}`,
418
+ `Lead status: ${c.leadStatus ?? "-"}`,
419
+ `Lifecycle: ${c.lifecycleStage ?? "-"}`,
420
+ `Updated at: ${c.updatedAt ?? "-"}`
421
+ ].join("\n");
422
+ }
423
+
424
+ // src/commands/api.ts
425
+ var KNOWN_SERVICES = ["auth", "crm"];
426
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
427
+ function registerApiCommand(program2) {
428
+ program2.command("api <service> <method> <path>").description("Generic passthrough to a Topolo service (escape hatch for untyped endpoints)").option("-d, --data <json>", "JSON body (for POST/PUT/PATCH)").option("--confirm", "Acknowledge a mutating request").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(
429
+ async (service, method, path, opts) => {
430
+ const mode = resolveOutputMode(opts.json);
431
+ try {
432
+ const upperMethod = method.toUpperCase();
433
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(upperMethod)) {
434
+ throw new Error(`Unsupported method: ${method}`);
435
+ }
436
+ if (!KNOWN_SERVICES.includes(service)) {
437
+ throw new Error(
438
+ `Unknown service "${service}". Known services: ${KNOWN_SERVICES.join(", ")}`
439
+ );
440
+ }
441
+ if (WRITE_METHODS.has(upperMethod) && !opts.confirm) {
442
+ throw new Error(`Mutating ${upperMethod} requires --confirm.`);
443
+ }
444
+ let body = void 0;
445
+ if (opts.data) {
446
+ try {
447
+ body = JSON.parse(opts.data);
448
+ } catch {
449
+ throw new Error("--data must be valid JSON");
450
+ }
451
+ }
452
+ const topolo = await buildClient();
453
+ if (!topolo) {
454
+ throw new Error(
455
+ "No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY."
456
+ );
457
+ }
458
+ const result = await topolo.client.request({
459
+ service,
460
+ method: upperMethod,
461
+ path,
462
+ body,
463
+ confirm: opts.confirm === true
464
+ });
465
+ emit(result, mode, () => JSON.stringify(result, null, 2));
466
+ } catch (err) {
467
+ fail(err, mode);
468
+ }
469
+ }
470
+ );
471
+ }
472
+
473
+ // src/index.ts
474
+ var program = new Command();
475
+ program.name("topolo").description(
476
+ "Topolo platform CLI. Every command is scoped to the authenticated organization \u2014 there is no way to target a different org."
477
+ ).version(CLI_VERSION);
478
+ registerAuthCommands(program);
479
+ registerWhoamiCommand(program);
480
+ registerCrmCommands(program);
481
+ registerApiCommand(program);
482
+ program.parseAsync(process.argv).catch((err) => {
483
+ process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
484
+ `);
485
+ process.exit(1);
486
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@topolo/cli",
3
+ "version": "0.1.1",
4
+ "description": "Command-line interface for the Topolo platform. Designed for humans and for agents (Claude, Codex, etc.) that prefer shell invocation.",
5
+ "type": "module",
6
+ "bin": {
7
+ "topolo": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "dev": "tsup --watch",
15
+ "typecheck": "tsc --noEmit",
16
+ "start": "node dist/index.js"
17
+ },
18
+ "dependencies": {
19
+ "@topolo/sdk": "file:../packages/topolo-sdk",
20
+ "commander": "^12.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.12.0",
24
+ "tsup": "^8.0.0",
25
+ "typescript": "^5.4.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ }
30
+ }