@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.
- package/README.md +131 -0
- package/dist/index.js +486 -0
- 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
|
+
}
|