@seclai/cli 1.1.1 → 1.2.0
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 +48 -1
- package/dist/cli.js +475 -9
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -42,10 +42,14 @@ npx add-mcp https://api.seclai.com/mcp --header "X-API-Key: $SECLAI_API_KEY" --n
|
|
|
42
42
|
|
|
43
43
|
## Documentation
|
|
44
44
|
|
|
45
|
-
Command reference (latest): https://seclai.github.io/seclai-cli/1.
|
|
45
|
+
Command reference (latest): https://seclai.github.io/seclai-cli/1.2.0/
|
|
46
46
|
|
|
47
47
|
## Authentication
|
|
48
48
|
|
|
49
|
+
The CLI supports two authentication methods:
|
|
50
|
+
|
|
51
|
+
### API Key
|
|
52
|
+
|
|
49
53
|
Set the `SECLAI_API_KEY` environment variable, or pass `--api-key` per-command:
|
|
50
54
|
|
|
51
55
|
```bash
|
|
@@ -55,18 +59,61 @@ export SECLAI_API_KEY="sk-..."
|
|
|
55
59
|
seclai --api-key "$SECLAI_API_KEY" agents list
|
|
56
60
|
```
|
|
57
61
|
|
|
62
|
+
### SSO (OAuth2 Bearer Token)
|
|
63
|
+
|
|
64
|
+
SSO works out of the box with built-in production defaults — no configuration needed:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Authenticate via browser (Authorization Code + PKCE)
|
|
68
|
+
seclai auth login
|
|
69
|
+
|
|
70
|
+
# Check authentication status
|
|
71
|
+
seclai auth status
|
|
72
|
+
|
|
73
|
+
# Refresh tokens manually
|
|
74
|
+
seclai auth refresh
|
|
75
|
+
|
|
76
|
+
# Log out (clears cached tokens)
|
|
77
|
+
seclai auth logout
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For custom SSO settings (e.g. staging environment), use `seclai configure sso`
|
|
81
|
+
or set environment variables:
|
|
82
|
+
|
|
83
|
+
| Variable | Description | Default |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `SECLAI_SSO_DOMAIN` | Cognito domain | `auth.seclai.com` |
|
|
86
|
+
| `SECLAI_SSO_CLIENT_ID` | Cognito app client ID | `4bgf8v9qmc5puivbaqon9n5lmr` |
|
|
87
|
+
| `SECLAI_SSO_REGION` | AWS region | `us-west-2` |
|
|
88
|
+
|
|
89
|
+
Use a named profile with `--profile`:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
seclai --profile staging agents list
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tokens are cached in `~/.seclai/sso/cache/` and auto-refreshed when expired.
|
|
96
|
+
|
|
58
97
|
## Environment Variables
|
|
59
98
|
|
|
60
99
|
| Variable | Description |
|
|
61
100
|
|---|---|
|
|
62
101
|
| `SECLAI_API_KEY` | Default API key (alternative to `--api-key`) |
|
|
63
102
|
| `SECLAI_API_URL` | Override API base URL (default: `https://api.seclai.com`) |
|
|
103
|
+
| `SECLAI_PROFILE` | Default SSO profile name (default: `default`) |
|
|
104
|
+
| `SECLAI_CONFIG_DIR` | Config directory path (default: `~/.seclai`) |
|
|
105
|
+
| `SECLAI_SSO_DOMAIN` | Override SSO domain (default: `auth.seclai.com`) |
|
|
106
|
+
| `SECLAI_SSO_CLIENT_ID` | Override SSO client ID (default: `4bgf8v9qmc5puivbaqon9n5lmr`) |
|
|
107
|
+
| `SECLAI_SSO_REGION` | Override SSO region (default: `us-west-2`) |
|
|
64
108
|
|
|
65
109
|
## Global Options
|
|
66
110
|
|
|
67
111
|
| Flag | Description |
|
|
68
112
|
|---|---|
|
|
69
113
|
| `--api-key <key>` | Seclai API key |
|
|
114
|
+
| `--profile <name>` | SSO profile name |
|
|
115
|
+
| `--account-id <id>` | Account ID (`X-Account-Id` header) |
|
|
116
|
+
| `--config-dir <path>` | Config directory path |
|
|
70
117
|
| `--compact` | Output compact (single-line) JSON |
|
|
71
118
|
| `-V, --version` | Print version |
|
|
72
119
|
|
package/dist/cli.js
CHANGED
|
@@ -72,6 +72,9 @@ function getCliVersion() {
|
|
|
72
72
|
function createClient(opts) {
|
|
73
73
|
const seclaiOpts = {};
|
|
74
74
|
if (opts.apiKey !== void 0) seclaiOpts.apiKey = opts.apiKey;
|
|
75
|
+
if (opts.profile !== void 0) seclaiOpts.profile = opts.profile;
|
|
76
|
+
if (opts.configDir !== void 0) seclaiOpts.configDir = opts.configDir;
|
|
77
|
+
if (opts.accountId !== void 0) seclaiOpts.accountId = opts.accountId;
|
|
75
78
|
const envUrl = process2.env.SECLAI_API_URL;
|
|
76
79
|
seclaiOpts.baseUrl = envUrl && envUrl.length > 0 ? envUrl : "https://api.seclai.com";
|
|
77
80
|
return new Seclai(seclaiOpts);
|
|
@@ -302,8 +305,8 @@ function register(program, rt) {
|
|
|
302
305
|
agents.command("upload-input").description("Upload a file as agent input.").argument("<agentId>", "Agent ID.").requiredOption("--file <path>", "File to upload.").option("--file-name <name>", "Override filename.").option("--mime-type <type>", "MIME type.").action(async (agentId, opts) => {
|
|
303
306
|
await run(rt, async () => {
|
|
304
307
|
const client = createClient(program.opts());
|
|
305
|
-
const { readFile:
|
|
306
|
-
const bytes = new Uint8Array(await
|
|
308
|
+
const { readFile: readFile5 } = await import("fs/promises");
|
|
309
|
+
const bytes = new Uint8Array(await readFile5(opts.file));
|
|
307
310
|
const o = { file: bytes };
|
|
308
311
|
if (opts.fileName) o.fileName = opts.fileName;
|
|
309
312
|
if (opts.mimeType) o.mimeType = opts.mimeType;
|
|
@@ -362,9 +365,11 @@ function register2(program, rt) {
|
|
|
362
365
|
const sources = program.command("sources").alias("source").description("Manage content sources.");
|
|
363
366
|
sources.command("list").description("List sources.").option("--page <n>", "Page number.", (v) => Number(v)).option("--limit <n>", "Page size.", (v) => Number(v)).option("--sort <field>", "Sort field.").option("--order <asc|desc>", "Sort direction.").option("--account-id <id>", "Filter by account ID.").action(async (opts) => {
|
|
364
367
|
await run(rt, async () => {
|
|
365
|
-
const
|
|
368
|
+
const globalOpts = program.opts();
|
|
369
|
+
const client = createClient(globalOpts);
|
|
366
370
|
const o = listOpts(opts);
|
|
367
|
-
|
|
371
|
+
const acctId = opts.accountId || globalOpts.accountId;
|
|
372
|
+
if (acctId) o.accountId = acctId;
|
|
368
373
|
printJson(rt, await client.listSources(o));
|
|
369
374
|
});
|
|
370
375
|
});
|
|
@@ -2165,6 +2170,453 @@ function register15(program, rt) {
|
|
|
2165
2170
|
});
|
|
2166
2171
|
}
|
|
2167
2172
|
|
|
2173
|
+
// src/commands/auth.ts
|
|
2174
|
+
import { randomBytes, createHash } from "crypto";
|
|
2175
|
+
import { createServer } from "http";
|
|
2176
|
+
import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
2177
|
+
import { join as join3 } from "path";
|
|
2178
|
+
import { URL as URL2, URLSearchParams } from "url";
|
|
2179
|
+
import process3 from "process";
|
|
2180
|
+
import {
|
|
2181
|
+
loadSsoProfile,
|
|
2182
|
+
readSsoCache,
|
|
2183
|
+
writeSsoCache,
|
|
2184
|
+
deleteSsoCache,
|
|
2185
|
+
isTokenValid
|
|
2186
|
+
} from "@seclai/sdk";
|
|
2187
|
+
function generateCodeVerifier() {
|
|
2188
|
+
return randomBytes(32).toString("base64url");
|
|
2189
|
+
}
|
|
2190
|
+
function computeCodeChallenge(verifier) {
|
|
2191
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
2192
|
+
}
|
|
2193
|
+
function waitForAuthCode(port, state) {
|
|
2194
|
+
return new Promise((resolve, reject) => {
|
|
2195
|
+
const server = createServer((req, res) => {
|
|
2196
|
+
const url = new URL2(req.url ?? "/", `http://localhost:${port}`);
|
|
2197
|
+
if (url.pathname !== "/callback") {
|
|
2198
|
+
res.writeHead(404);
|
|
2199
|
+
res.end();
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const code = url.searchParams.get("code");
|
|
2203
|
+
const returnedState = url.searchParams.get("state");
|
|
2204
|
+
const error = url.searchParams.get("error");
|
|
2205
|
+
if (error) {
|
|
2206
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2207
|
+
res.end("<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>");
|
|
2208
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
2209
|
+
server.close();
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (!code || returnedState !== state) {
|
|
2213
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
2214
|
+
res.end("<html><body><h2>Invalid callback</h2></body></html>");
|
|
2215
|
+
reject(new Error("Invalid callback: missing code or state mismatch"));
|
|
2216
|
+
server.close();
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2220
|
+
res.end("<html><body><h2>Authenticated successfully!</h2><p>You can close this tab.</p></body></html>");
|
|
2221
|
+
resolve({
|
|
2222
|
+
code,
|
|
2223
|
+
cleanup: () => server.close()
|
|
2224
|
+
});
|
|
2225
|
+
});
|
|
2226
|
+
server.listen(port, "127.0.0.1");
|
|
2227
|
+
server.on("error", reject);
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
async function exchangeCodeForTokens(profile, code, codeVerifier, redirectUri) {
|
|
2231
|
+
const tokenUrl = `https://${profile.ssoDomain}/oauth2/token`;
|
|
2232
|
+
const body = new URLSearchParams({
|
|
2233
|
+
grant_type: "authorization_code",
|
|
2234
|
+
client_id: profile.ssoClientId,
|
|
2235
|
+
code,
|
|
2236
|
+
redirect_uri: redirectUri,
|
|
2237
|
+
code_verifier: codeVerifier
|
|
2238
|
+
});
|
|
2239
|
+
const resp = await fetch(tokenUrl, {
|
|
2240
|
+
method: "POST",
|
|
2241
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
2242
|
+
body: body.toString()
|
|
2243
|
+
});
|
|
2244
|
+
if (!resp.ok) {
|
|
2245
|
+
const text = await resp.text();
|
|
2246
|
+
throw new Error(`Token exchange failed (HTTP ${resp.status}): ${text}`);
|
|
2247
|
+
}
|
|
2248
|
+
const data = await resp.json();
|
|
2249
|
+
const expiresAt = new Date(Date.now() + data.expires_in * 1e3).toISOString();
|
|
2250
|
+
const entry = {
|
|
2251
|
+
accessToken: data.access_token,
|
|
2252
|
+
expiresAt,
|
|
2253
|
+
clientId: profile.ssoClientId,
|
|
2254
|
+
region: profile.ssoRegion,
|
|
2255
|
+
cognitoDomain: profile.ssoDomain
|
|
2256
|
+
};
|
|
2257
|
+
if (data.refresh_token) entry.refreshToken = data.refresh_token;
|
|
2258
|
+
if (data.id_token) entry.idToken = data.id_token;
|
|
2259
|
+
return entry;
|
|
2260
|
+
}
|
|
2261
|
+
var DEFAULT_CALLBACK_PORT = 9876;
|
|
2262
|
+
function resolveBaseUrl() {
|
|
2263
|
+
const envUrl = process3.env.SECLAI_API_URL;
|
|
2264
|
+
return envUrl && envUrl.length > 0 ? envUrl : "https://api.seclai.com";
|
|
2265
|
+
}
|
|
2266
|
+
async function fetchAccountId(accessToken) {
|
|
2267
|
+
const baseUrl = resolveBaseUrl();
|
|
2268
|
+
const resp = await fetch(`${baseUrl}/me`, {
|
|
2269
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
2270
|
+
});
|
|
2271
|
+
if (!resp.ok) {
|
|
2272
|
+
const text = await resp.text();
|
|
2273
|
+
throw new Error(`Failed to resolve account ID from /me (HTTP ${resp.status}): ${text}`);
|
|
2274
|
+
}
|
|
2275
|
+
const data = await resp.json();
|
|
2276
|
+
return data.account_id;
|
|
2277
|
+
}
|
|
2278
|
+
async function updateConfigKey(configDir, profileName, key, value) {
|
|
2279
|
+
const configPath = join3(configDir, "config");
|
|
2280
|
+
let content = "";
|
|
2281
|
+
try {
|
|
2282
|
+
content = await readFile3(configPath, "utf-8");
|
|
2283
|
+
} catch {
|
|
2284
|
+
}
|
|
2285
|
+
const sectionHeader = profileName === "default" ? "[default]" : `[profile ${profileName}]`;
|
|
2286
|
+
const sectionIdx = content.indexOf(sectionHeader);
|
|
2287
|
+
if (sectionIdx === -1) {
|
|
2288
|
+
if (content.length > 0 && !content.endsWith("\n")) {
|
|
2289
|
+
content += "\n";
|
|
2290
|
+
}
|
|
2291
|
+
content += `
|
|
2292
|
+
${sectionHeader}
|
|
2293
|
+
${key} = ${value}
|
|
2294
|
+
`;
|
|
2295
|
+
} else {
|
|
2296
|
+
const afterHeader = sectionIdx + sectionHeader.length;
|
|
2297
|
+
const nextSectionMatch = content.slice(afterHeader).match(/\n\[/);
|
|
2298
|
+
const sectionEnd = nextSectionMatch ? afterHeader + nextSectionMatch.index : content.length;
|
|
2299
|
+
const sectionBody = content.slice(afterHeader, sectionEnd);
|
|
2300
|
+
const keyRegex = new RegExp(`^${key}\\s*=.*$`, "m");
|
|
2301
|
+
let newSectionBody;
|
|
2302
|
+
if (keyRegex.test(sectionBody)) {
|
|
2303
|
+
newSectionBody = sectionBody.replace(keyRegex, `${key} = ${value}`);
|
|
2304
|
+
} else {
|
|
2305
|
+
const trimmed = sectionBody.trimEnd();
|
|
2306
|
+
newSectionBody = `${trimmed}
|
|
2307
|
+
${key} = ${value}
|
|
2308
|
+
`;
|
|
2309
|
+
}
|
|
2310
|
+
content = content.slice(0, afterHeader) + newSectionBody + content.slice(sectionEnd);
|
|
2311
|
+
}
|
|
2312
|
+
const { mkdir: mkdir4 } = await import("fs/promises");
|
|
2313
|
+
await mkdir4(configDir, { recursive: true });
|
|
2314
|
+
await writeFile3(configPath, content, { mode: 384 });
|
|
2315
|
+
}
|
|
2316
|
+
function resolveProfile(opts) {
|
|
2317
|
+
const profileName = opts.profile || process3.env.SECLAI_PROFILE || "default";
|
|
2318
|
+
let configDir = opts.configDir || process3.env.SECLAI_CONFIG_DIR;
|
|
2319
|
+
if (!configDir) {
|
|
2320
|
+
const home = process3.env.HOME ?? process3.env.USERPROFILE;
|
|
2321
|
+
if (home && home.trim() !== "") {
|
|
2322
|
+
configDir = join3(home, ".seclai");
|
|
2323
|
+
} else {
|
|
2324
|
+
configDir = join3(process3.cwd(), ".seclai");
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
return { profileName, configDir };
|
|
2328
|
+
}
|
|
2329
|
+
async function loadProfile(rt, opts) {
|
|
2330
|
+
const { profileName, configDir } = resolveProfile(opts);
|
|
2331
|
+
const profile = await loadSsoProfile(configDir, profileName);
|
|
2332
|
+
return { profile, profileName, configDir };
|
|
2333
|
+
}
|
|
2334
|
+
function register16(program, rt) {
|
|
2335
|
+
const group = program.command("auth").description("SSO authentication (login/logout/status/refresh).");
|
|
2336
|
+
group.command("login").description("Authenticate via SSO using Authorization Code + PKCE flow.").option("--port <port>", "Local callback port", String(DEFAULT_CALLBACK_PORT)).option("--no-browser", "Print the URL instead of opening a browser").action(async (opts) => {
|
|
2337
|
+
await run(rt, async () => {
|
|
2338
|
+
const globalOpts = program.opts();
|
|
2339
|
+
const { profile, profileName, configDir } = await loadProfile(rt, globalOpts);
|
|
2340
|
+
const port = parseInt(opts.port ?? String(DEFAULT_CALLBACK_PORT), 10);
|
|
2341
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2342
|
+
throw new Error(`Invalid port: ${opts.port}. Must be an integer between 1 and 65535.`);
|
|
2343
|
+
}
|
|
2344
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
2345
|
+
const codeVerifier = generateCodeVerifier();
|
|
2346
|
+
const codeChallenge = computeCodeChallenge(codeVerifier);
|
|
2347
|
+
const state = randomBytes(16).toString("hex");
|
|
2348
|
+
const authUrl = new URL2(`https://${profile.ssoDomain}/oauth2/authorize`);
|
|
2349
|
+
authUrl.searchParams.set("response_type", "code");
|
|
2350
|
+
authUrl.searchParams.set("client_id", profile.ssoClientId);
|
|
2351
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
2352
|
+
authUrl.searchParams.set("scope", "openid profile email");
|
|
2353
|
+
authUrl.searchParams.set("state", state);
|
|
2354
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
2355
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
2356
|
+
const authUrlStr = authUrl.toString();
|
|
2357
|
+
const codePromise = waitForAuthCode(port, state);
|
|
2358
|
+
if (opts.browser !== false) {
|
|
2359
|
+
const { spawn } = await import("child_process");
|
|
2360
|
+
const openCmd = process3.platform === "darwin" ? { cmd: "open", args: [authUrlStr] } : process3.platform === "win32" ? { cmd: "cmd", args: ["/c", "start", "", authUrlStr] } : { cmd: "xdg-open", args: [authUrlStr] };
|
|
2361
|
+
spawn(openCmd.cmd, openCmd.args, { stdio: "ignore", detached: true }).unref();
|
|
2362
|
+
rt.writeErr(`Opening browser for authentication...
|
|
2363
|
+
`);
|
|
2364
|
+
} else {
|
|
2365
|
+
rt.writeErr(`Open this URL in your browser:
|
|
2366
|
+
|
|
2367
|
+
${authUrlStr}
|
|
2368
|
+
|
|
2369
|
+
`);
|
|
2370
|
+
}
|
|
2371
|
+
rt.writeErr("Waiting for authentication callback...\n");
|
|
2372
|
+
const { code, cleanup } = await codePromise;
|
|
2373
|
+
let tokens;
|
|
2374
|
+
try {
|
|
2375
|
+
rt.writeErr("Exchanging code for tokens...\n");
|
|
2376
|
+
tokens = await exchangeCodeForTokens(profile, code, codeVerifier, redirectUri);
|
|
2377
|
+
await writeSsoCache(configDir, profile, tokens);
|
|
2378
|
+
} finally {
|
|
2379
|
+
cleanup();
|
|
2380
|
+
}
|
|
2381
|
+
let accountId = profile.ssoAccountId;
|
|
2382
|
+
try {
|
|
2383
|
+
rt.writeErr("Resolving account ID...\n");
|
|
2384
|
+
accountId = await fetchAccountId(tokens.accessToken);
|
|
2385
|
+
await updateConfigKey(configDir, profileName, "sso_account_id", accountId);
|
|
2386
|
+
} catch {
|
|
2387
|
+
rt.writeErr("Warning: Could not resolve account ID from /me. You can set it manually with `seclai configure sso`.\n");
|
|
2388
|
+
}
|
|
2389
|
+
rt.writeErr("Successfully authenticated!\n");
|
|
2390
|
+
const loginResult = {
|
|
2391
|
+
status: "authenticated",
|
|
2392
|
+
profile: profileName,
|
|
2393
|
+
expiresAt: tokens.expiresAt
|
|
2394
|
+
};
|
|
2395
|
+
if (accountId) loginResult.accountId = accountId;
|
|
2396
|
+
printJson(rt, loginResult);
|
|
2397
|
+
});
|
|
2398
|
+
});
|
|
2399
|
+
group.command("logout").description("Remove cached SSO tokens for the current profile.").action(async () => {
|
|
2400
|
+
await run(rt, async () => {
|
|
2401
|
+
const globalOpts = program.opts();
|
|
2402
|
+
const { profile, profileName, configDir } = await loadProfile(rt, globalOpts);
|
|
2403
|
+
await deleteSsoCache(configDir, profile);
|
|
2404
|
+
rt.writeErr("Logged out successfully.\n");
|
|
2405
|
+
printJson(rt, { status: "logged_out", profile: profileName });
|
|
2406
|
+
});
|
|
2407
|
+
});
|
|
2408
|
+
group.command("status").description("Show current authentication status for the active profile.").action(async () => {
|
|
2409
|
+
await run(rt, async () => {
|
|
2410
|
+
const globalOpts = program.opts();
|
|
2411
|
+
const { profile: profileLoaded, profileName, configDir } = await loadProfile(rt, globalOpts);
|
|
2412
|
+
const cached = await readSsoCache(configDir, profileLoaded);
|
|
2413
|
+
if (!cached) {
|
|
2414
|
+
const notAuthResult = {
|
|
2415
|
+
profile: profileName,
|
|
2416
|
+
status: "not_authenticated"
|
|
2417
|
+
};
|
|
2418
|
+
if (profileLoaded.ssoAccountId) notAuthResult.accountId = profileLoaded.ssoAccountId;
|
|
2419
|
+
printJson(rt, notAuthResult);
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const valid = isTokenValid(cached);
|
|
2423
|
+
const statusResult = {
|
|
2424
|
+
profile: profileName,
|
|
2425
|
+
status: valid ? "authenticated" : "expired",
|
|
2426
|
+
expiresAt: cached.expiresAt,
|
|
2427
|
+
hasRefreshToken: Boolean(cached.refreshToken)
|
|
2428
|
+
};
|
|
2429
|
+
if (profileLoaded.ssoAccountId) statusResult.accountId = profileLoaded.ssoAccountId;
|
|
2430
|
+
printJson(rt, statusResult);
|
|
2431
|
+
});
|
|
2432
|
+
});
|
|
2433
|
+
group.command("refresh").description("Manually refresh the SSO token for the current profile.").action(async () => {
|
|
2434
|
+
await run(rt, async () => {
|
|
2435
|
+
const globalOpts = program.opts();
|
|
2436
|
+
const { profile, profileName, configDir } = await loadProfile(rt, globalOpts);
|
|
2437
|
+
const cached = await readSsoCache(configDir, profile);
|
|
2438
|
+
if (!cached?.refreshToken) {
|
|
2439
|
+
throw new Error("No cached token with refresh token. Run `seclai auth login` first.");
|
|
2440
|
+
}
|
|
2441
|
+
const tokenUrl = `https://${profile.ssoDomain}/oauth2/token`;
|
|
2442
|
+
const body = new URLSearchParams({
|
|
2443
|
+
grant_type: "refresh_token",
|
|
2444
|
+
client_id: profile.ssoClientId,
|
|
2445
|
+
refresh_token: cached.refreshToken
|
|
2446
|
+
});
|
|
2447
|
+
const resp = await fetch(tokenUrl, {
|
|
2448
|
+
method: "POST",
|
|
2449
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
2450
|
+
body: body.toString()
|
|
2451
|
+
});
|
|
2452
|
+
if (!resp.ok) {
|
|
2453
|
+
const text = await resp.text();
|
|
2454
|
+
throw new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
|
|
2455
|
+
}
|
|
2456
|
+
const data = await resp.json();
|
|
2457
|
+
const refreshed = {
|
|
2458
|
+
accessToken: data.access_token,
|
|
2459
|
+
refreshToken: data.refresh_token ?? cached.refreshToken,
|
|
2460
|
+
expiresAt: new Date(Date.now() + data.expires_in * 1e3).toISOString(),
|
|
2461
|
+
clientId: profile.ssoClientId,
|
|
2462
|
+
region: profile.ssoRegion,
|
|
2463
|
+
cognitoDomain: profile.ssoDomain
|
|
2464
|
+
};
|
|
2465
|
+
if (data.id_token) refreshed.idToken = data.id_token;
|
|
2466
|
+
await writeSsoCache(configDir, profile, refreshed);
|
|
2467
|
+
rt.writeErr("Token refreshed successfully.\n");
|
|
2468
|
+
printJson(rt, {
|
|
2469
|
+
status: "refreshed",
|
|
2470
|
+
profile: profileName,
|
|
2471
|
+
expiresAt: refreshed.expiresAt
|
|
2472
|
+
});
|
|
2473
|
+
});
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// src/commands/configure.ts
|
|
2478
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
2479
|
+
import { join as join4 } from "path";
|
|
2480
|
+
import process4 from "process";
|
|
2481
|
+
import { createInterface } from "readline";
|
|
2482
|
+
import { DEFAULT_SSO_DOMAIN, DEFAULT_SSO_CLIENT_ID, DEFAULT_SSO_REGION } from "@seclai/sdk";
|
|
2483
|
+
function prompt(rt, question, defaultValue) {
|
|
2484
|
+
return new Promise((resolve) => {
|
|
2485
|
+
const rl = createInterface({
|
|
2486
|
+
input: rt.stdin,
|
|
2487
|
+
output: { write: (s) => {
|
|
2488
|
+
rt.writeErr(s);
|
|
2489
|
+
return true;
|
|
2490
|
+
} },
|
|
2491
|
+
terminal: false
|
|
2492
|
+
});
|
|
2493
|
+
const display = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
|
|
2494
|
+
rt.writeErr(display);
|
|
2495
|
+
rl.once("line", (answer) => {
|
|
2496
|
+
rl.close();
|
|
2497
|
+
resolve(answer.trim() || defaultValue || "");
|
|
2498
|
+
});
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
function resolveConfigDir(opts) {
|
|
2502
|
+
if (opts.configDir) return opts.configDir;
|
|
2503
|
+
const env = process4.env.SECLAI_CONFIG_DIR;
|
|
2504
|
+
if (env) return env;
|
|
2505
|
+
const home = process4.env.HOME ?? process4.env.USERPROFILE ?? "";
|
|
2506
|
+
return join4(home, ".seclai");
|
|
2507
|
+
}
|
|
2508
|
+
function register17(program, rt) {
|
|
2509
|
+
const group = program.command("configure").description("Configure CLI profiles and settings.");
|
|
2510
|
+
group.command("sso").description("Configure an SSO profile with optional overrides. Defaults to production Seclai SSO.").option("--profile-name <name>", "Profile name to configure (default: from --profile flag)").action(async (opts) => {
|
|
2511
|
+
await run(rt, async () => {
|
|
2512
|
+
const globalOpts = program.opts();
|
|
2513
|
+
const profileName = opts.profileName || globalOpts.profile || "default";
|
|
2514
|
+
const configDir = resolveConfigDir(globalOpts);
|
|
2515
|
+
const configPath = join4(configDir, "config");
|
|
2516
|
+
rt.writeErr(`
|
|
2517
|
+
Configuring SSO profile "${profileName}".
|
|
2518
|
+
`);
|
|
2519
|
+
rt.writeErr(`Defaults: domain=${DEFAULT_SSO_DOMAIN}, region=${DEFAULT_SSO_REGION}
|
|
2520
|
+
`);
|
|
2521
|
+
rt.writeErr(`Press Enter to accept defaults.
|
|
2522
|
+
|
|
2523
|
+
`);
|
|
2524
|
+
const domain = await prompt(rt, "SSO domain", DEFAULT_SSO_DOMAIN);
|
|
2525
|
+
const clientId = await prompt(rt, "SSO client ID", DEFAULT_SSO_CLIENT_ID);
|
|
2526
|
+
const region = await prompt(rt, "SSO region", DEFAULT_SSO_REGION);
|
|
2527
|
+
const accountId = await prompt(rt, "Account ID (optional, resolved after login)");
|
|
2528
|
+
const isDefault = domain === DEFAULT_SSO_DOMAIN && clientId === DEFAULT_SSO_CLIENT_ID && region === DEFAULT_SSO_REGION && !accountId;
|
|
2529
|
+
if (isDefault && profileName === "default") {
|
|
2530
|
+
rt.writeErr(`
|
|
2531
|
+
Using built-in defaults \u2014 no config file needed.
|
|
2532
|
+
`);
|
|
2533
|
+
rt.writeErr(`Run \`seclai auth login\` to authenticate.
|
|
2534
|
+
`);
|
|
2535
|
+
printJson(rt, {
|
|
2536
|
+
profile: profileName,
|
|
2537
|
+
sso_domain: domain,
|
|
2538
|
+
sso_client_id: clientId,
|
|
2539
|
+
sso_region: region,
|
|
2540
|
+
note: "using built-in defaults"
|
|
2541
|
+
});
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
let content = "";
|
|
2545
|
+
try {
|
|
2546
|
+
content = await readFile4(configPath, "utf-8");
|
|
2547
|
+
} catch {
|
|
2548
|
+
}
|
|
2549
|
+
const sectionHeader = profileName === "default" ? "[default]" : `[profile ${profileName}]`;
|
|
2550
|
+
const lines = [];
|
|
2551
|
+
if (domain !== DEFAULT_SSO_DOMAIN) lines.push(`sso_domain = ${domain}`);
|
|
2552
|
+
if (clientId !== DEFAULT_SSO_CLIENT_ID) lines.push(`sso_client_id = ${clientId}`);
|
|
2553
|
+
if (region !== DEFAULT_SSO_REGION) lines.push(`sso_region = ${region}`);
|
|
2554
|
+
if (accountId) lines.push(`sso_account_id = ${accountId}`);
|
|
2555
|
+
const sectionBody = lines.join("\n");
|
|
2556
|
+
const sectionRegex = profileName === "default" ? /^\[default\][^\[]*(?=\[|$(?![\s\S]))/m : new RegExp(`^\\[profile ${escapeRegExp(profileName)}\\][^\\[]*(?=\\[|$(?![\\s\\S]))`, "m");
|
|
2557
|
+
if (sectionRegex.test(content)) {
|
|
2558
|
+
content = content.replace(sectionRegex, `${sectionHeader}
|
|
2559
|
+
${sectionBody}
|
|
2560
|
+
`);
|
|
2561
|
+
} else {
|
|
2562
|
+
if (content.length > 0 && !content.endsWith("\n")) {
|
|
2563
|
+
content += "\n";
|
|
2564
|
+
}
|
|
2565
|
+
content += `
|
|
2566
|
+
${sectionHeader}
|
|
2567
|
+
${sectionBody}
|
|
2568
|
+
`;
|
|
2569
|
+
}
|
|
2570
|
+
await mkdir3(configDir, { recursive: true });
|
|
2571
|
+
await writeFile4(configPath, content, { mode: 384 });
|
|
2572
|
+
rt.writeErr(`
|
|
2573
|
+
Profile "${profileName}" saved to ${configPath}
|
|
2574
|
+
`);
|
|
2575
|
+
rt.writeErr(`Run \`seclai auth login --profile ${profileName}\` to authenticate.
|
|
2576
|
+
`);
|
|
2577
|
+
const result = {
|
|
2578
|
+
profile: profileName,
|
|
2579
|
+
configFile: configPath,
|
|
2580
|
+
sso_domain: domain,
|
|
2581
|
+
sso_client_id: clientId,
|
|
2582
|
+
sso_region: region
|
|
2583
|
+
};
|
|
2584
|
+
if (accountId) result.sso_account_id = accountId;
|
|
2585
|
+
printJson(rt, result);
|
|
2586
|
+
});
|
|
2587
|
+
});
|
|
2588
|
+
group.command("list").description("List all configured profiles.").action(async () => {
|
|
2589
|
+
await run(rt, async () => {
|
|
2590
|
+
const globalOpts = program.opts();
|
|
2591
|
+
const configDir = resolveConfigDir(globalOpts);
|
|
2592
|
+
const configPath = join4(configDir, "config");
|
|
2593
|
+
let content;
|
|
2594
|
+
try {
|
|
2595
|
+
content = await readFile4(configPath, "utf-8");
|
|
2596
|
+
} catch {
|
|
2597
|
+
printJson(rt, { profiles: [], configFile: configPath });
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
const profiles = [];
|
|
2601
|
+
for (const line of content.split("\n")) {
|
|
2602
|
+
const trimmed = line.trim();
|
|
2603
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
2604
|
+
const raw = trimmed.slice(1, -1).trim();
|
|
2605
|
+
if (raw.startsWith("profile ")) {
|
|
2606
|
+
profiles.push(raw.slice("profile ".length).trim());
|
|
2607
|
+
} else {
|
|
2608
|
+
profiles.push(raw);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
printJson(rt, { profiles, configFile: configPath });
|
|
2613
|
+
});
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
function escapeRegExp(s) {
|
|
2617
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2168
2620
|
// src/cli.ts
|
|
2169
2621
|
function createProgram(rt = defaultRuntime()) {
|
|
2170
2622
|
const program = new Command();
|
|
@@ -2178,6 +2630,15 @@ All commands return JSON to stdout, making it easy to pipe into jq or other tool
|
|
|
2178
2630
|
).version(cliVersion, "-V, --version", "output the version").option(
|
|
2179
2631
|
"--api-key <key>",
|
|
2180
2632
|
"Seclai API key (defaults to SECLAI_API_KEY)."
|
|
2633
|
+
).option(
|
|
2634
|
+
"--profile <name>",
|
|
2635
|
+
"SSO profile name (defaults to SECLAI_PROFILE, then 'default')."
|
|
2636
|
+
).option(
|
|
2637
|
+
"--account-id <id>",
|
|
2638
|
+
"Account ID for multi-org targeting (X-Account-Id header)."
|
|
2639
|
+
).option(
|
|
2640
|
+
"--config-dir <path>",
|
|
2641
|
+
"Config directory (defaults to SECLAI_CONFIG_DIR, then ~/.seclai)."
|
|
2181
2642
|
).option(
|
|
2182
2643
|
"--compact",
|
|
2183
2644
|
"Output compact JSON (no indentation)."
|
|
@@ -2186,16 +2647,19 @@ All commands return JSON to stdout, making it easy to pipe into jq or other tool
|
|
|
2186
2647
|
"after",
|
|
2187
2648
|
`
|
|
2188
2649
|
Environment:
|
|
2189
|
-
SECLAI_API_KEY
|
|
2190
|
-
SECLAI_API_URL
|
|
2650
|
+
SECLAI_API_KEY Default API key (alternative to --api-key)
|
|
2651
|
+
SECLAI_API_URL Override API base URL (default: https://api.seclai.com)
|
|
2652
|
+
SECLAI_PROFILE Default SSO profile (alternative to --profile)
|
|
2653
|
+
SECLAI_CONFIG_DIR Config directory (alternative to --config-dir)
|
|
2191
2654
|
|
|
2192
2655
|
Examples:
|
|
2193
2656
|
seclai agents list
|
|
2194
2657
|
seclai agents run <agentId> --json '{"input":"Hello"}'
|
|
2195
2658
|
seclai agents run <agentId> --json '{"input":"Hi"}' --events
|
|
2196
|
-
seclai
|
|
2197
|
-
seclai
|
|
2198
|
-
seclai
|
|
2659
|
+
seclai configure sso
|
|
2660
|
+
seclai auth login
|
|
2661
|
+
seclai auth status
|
|
2662
|
+
seclai sources list --profile dev
|
|
2199
2663
|
npx @seclai/cli agents list
|
|
2200
2664
|
`
|
|
2201
2665
|
);
|
|
@@ -2223,6 +2687,8 @@ Examples:
|
|
|
2223
2687
|
register13(program, rt);
|
|
2224
2688
|
register14(program, rt);
|
|
2225
2689
|
register15(program, rt);
|
|
2690
|
+
register16(program, rt);
|
|
2691
|
+
register17(program, rt);
|
|
2226
2692
|
return program;
|
|
2227
2693
|
}
|
|
2228
2694
|
async function runCli(argv, rt = defaultRuntime()) {
|