@index365/cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 index365
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @index365/cli
2
+
3
+ Run [index365](https://index365.co) website audits from your terminal, CI, or an AI agent. The CLI is a thin wrapper over the public `/api/v1`, so anything it does, your own agents can do too.
4
+
5
+ index365 runs two audits: **AI-Readiness** (how well AI agents and AI search can read a site) and **Marketing Signal** (can demand find the site, trust the offer, act, and be measured). Each run produces a score plus findings with stable IDs, evidence, and machine-readable remediation.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @index365/cli
11
+ # or run without installing:
12
+ npx @index365/cli --help
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```bash
18
+ index365 login # paste an i365_ key from the dashboard (Org settings -> API keys)
19
+ index365 doctor # verify auth, scopes, and API reachability
20
+ index365 projects list # find a projectId
21
+ index365 runs start --project <id> --wait # run an AI-readiness audit, wait for the score
22
+ index365 marketing run --project <id> --wait # or run a Marketing Signal audit (separate command tree)
23
+ index365 reports context <runId> # compact, agent-ready report payload
24
+ index365 findings list --run <runId> # triage findings
25
+ index365 findings get --run <runId> <findingId> # full detail + remediation
26
+ ```
27
+
28
+ Add `--json` to any command for machine-readable output. Keys live at `~/.config/index365/config.json` (mode 0600); `INDEX365_API_KEY` overrides the file.
29
+
30
+ ## Exit codes
31
+
32
+ `0` ok · `1` error · `2` usage · `3` auth · `4` not found · `5` quota/conflict/rate
33
+
34
+ ## MCP
35
+
36
+ ```bash
37
+ index365 mcp config # prints ready-to-paste config for Claude Code, Codex, and Cursor
38
+ ```
39
+
40
+ ## Docs
41
+
42
+ Full reference: https://index365.co/docs/developers/cli
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.mjs";
3
+
4
+ process.exitCode = await run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@index365/cli",
3
+ "version": "0.1.0",
4
+ "description": "index365 CLI - run AI-readiness and marketing-signal audits and read findings from your terminal, CI, or agents. Wraps the public /api/v1.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "index365",
8
+ "homepage": "https://index365.co/docs/developers/cli",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/index365usa/index365.git",
12
+ "directory": "packages/cli"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/index365usa/index365/issues"
16
+ },
17
+ "keywords": ["index365", "website-audit", "ai-readiness", "seo", "cli", "agent", "mcp"],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "bin": {
22
+ "index365": "./bin/index365.mjs"
23
+ },
24
+ "files": ["bin", "src"],
25
+ "engines": {
26
+ "node": ">=20.18.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc -p tsconfig.json",
30
+ "test": "vitest run",
31
+ "prepublishOnly": "npm run typecheck && npm run test"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.19.19",
35
+ "typescript": "^5.6.3",
36
+ "vitest": "^3.2.6"
37
+ }
38
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,580 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { parseArgs } from "node:util";
6
+ import { CliError, EXIT, apiRequest } from "./client.mjs";
7
+ import {
8
+ configPath,
9
+ deleteConfigFile,
10
+ readConfigFile,
11
+ resolveSettings,
12
+ writeConfigFile,
13
+ } from "./config.mjs";
14
+
15
+ export const CLI_VERSION = "0.1.0";
16
+
17
+ const HELP = `index365 - website audits from your terminal, CI, or agents
18
+
19
+ USAGE
20
+ index365 <command> [options]
21
+
22
+ COMMANDS
23
+ login Save an API key (prompts; or --key / INDEX365_API_KEY)
24
+ logout Remove the saved API key
25
+ doctor Verify auth, scopes, API reachability, contract version
26
+ projects list List projects in your organization
27
+ runs start Start a paid AI-readiness audit (--project <id>, optional --wait)
28
+ runs get Show one run (<runId>)
29
+ findings list List findings for a run (--run <id>)
30
+ findings get Show one finding (--run <id> <findingId>)
31
+ reports context Compact agent report context for a run (<runId>)
32
+ reports download Download the PDF report to .index365/ (<runId> [--output <file>])
33
+ marketing run Start a Marketing Signal audit (--project <id>, optional --wait)
34
+ marketing report Latest Marketing Signal report for a project (--project <id>)
35
+ marketing findings Marketing findings (--run <id> | --project <id>, optional --stage)
36
+ integrations list Connected-signal providers + status (--project <id>)
37
+ mcp config Print MCP server config for Claude Code / Codex / Cursor
38
+
39
+ GLOBAL OPTIONS
40
+ --json Machine-readable output (recommended for agents)
41
+ --api-url <url> Override the API base URL (default https://index365.co)
42
+ --help, -h Show help · --version, -V show version
43
+
44
+ EXIT CODES
45
+ 0 ok · 1 error · 2 usage · 3 auth · 4 not found · 5 quota/conflict/rate
46
+
47
+ AUTH
48
+ Keys are created from an active Agency workspace in the dashboard
49
+ (Org settings -> API keys) and stored at ~/.config/index365/config.json
50
+ (0600). INDEX365_API_KEY overrides the file.
51
+ `;
52
+
53
+ function out(io, line) {
54
+ io.stdout(line);
55
+ }
56
+
57
+ function printJson(io, value) {
58
+ io.stdout(JSON.stringify(value, null, 2));
59
+ }
60
+
61
+ /** Parse argv for one command; throws CliError(USAGE) on unknown flags. */
62
+ function parse(argv, extraOptions = {}) {
63
+ try {
64
+ return parseArgs({
65
+ args: argv,
66
+ allowPositionals: true,
67
+ options: {
68
+ json: { type: "boolean", default: false },
69
+ "api-url": { type: "string" },
70
+ help: { type: "boolean", short: "h", default: false },
71
+ ...extraOptions,
72
+ },
73
+ });
74
+ } catch (err) {
75
+ throw new CliError(`${err.message}\nRun 'index365 --help' for usage.`, EXIT.USAGE);
76
+ }
77
+ }
78
+
79
+ function settingsFor(values, env) {
80
+ const settings = resolveSettings(env);
81
+ if (values["api-url"]) settings.apiUrl = String(values["api-url"]).replace(/\/+$/, "");
82
+ return settings;
83
+ }
84
+
85
+ async function promptForKey(io) {
86
+ if (!input.isTTY) {
87
+ throw new CliError(
88
+ "No TTY for the key prompt. Pass --key <key> or set INDEX365_API_KEY.",
89
+ EXIT.USAGE,
90
+ );
91
+ }
92
+ const rl = createInterface({ input, output });
93
+ try {
94
+ const answer = await rl.question("Paste your API key (i365_...): ");
95
+ return answer.trim();
96
+ } finally {
97
+ rl.close();
98
+ }
99
+ }
100
+
101
+ async function cmdLogin(argv, io, env) {
102
+ const { values } = parse(argv, { key: { type: "string" } });
103
+ const key = (values.key ?? env.INDEX365_API_KEY ?? "").trim() || (await promptForKey(io));
104
+ if (!key.startsWith("i365_")) {
105
+ throw new CliError("That does not look like an index365 API key (i365_...).", EXIT.USAGE);
106
+ }
107
+ const settings = settingsFor(values, env);
108
+ settings.apiKey = key;
109
+
110
+ const me = await apiRequest(settings, "GET", "/api/v1/me", { fetchImpl: io.fetch });
111
+ const config = readConfigFile(env);
112
+ config.apiKey = key;
113
+ if (values["api-url"]) config.apiUrl = settings.apiUrl;
114
+ const file = writeConfigFile(config, env);
115
+
116
+ if (values.json) {
117
+ printJson(io, { ok: true, configPath: file, ...me });
118
+ } else {
119
+ out(
120
+ io,
121
+ `Logged in as '${me.keyName}' (${me.keyPrefix}…) for org ${me.organizationSlug ?? me.organizationId}.`,
122
+ );
123
+ out(io, `Scopes: ${me.scopes.join(", ")}`);
124
+ out(io, `Saved to ${file} (0600).`);
125
+ }
126
+ return EXIT.OK;
127
+ }
128
+
129
+ async function cmdLogout(argv, io, env) {
130
+ const { values } = parse(argv);
131
+ const removed = deleteConfigFile(env);
132
+ if (values.json) printJson(io, { ok: true, removed });
133
+ else out(io, removed ? `Removed ${configPath(env)}.` : "No saved config to remove.");
134
+ return EXIT.OK;
135
+ }
136
+
137
+ async function cmdDoctor(argv, io, env) {
138
+ const { values } = parse(argv);
139
+ const settings = settingsFor(values, env);
140
+ const checks = {
141
+ apiUrl: settings.apiUrl,
142
+ keyConfigured: Boolean(settings.apiKey),
143
+ keySource: settings.source,
144
+ reachable: false,
145
+ keyPrefix: null,
146
+ organization: null,
147
+ scopes: [],
148
+ apiVersion: null,
149
+ contractVersion: null,
150
+ };
151
+ let ok = checks.keyConfigured;
152
+ if (settings.apiKey) {
153
+ try {
154
+ const me = await apiRequest(settings, "GET", "/api/v1/me", { fetchImpl: io.fetch });
155
+ checks.reachable = true;
156
+ checks.keyPrefix = me.keyPrefix;
157
+ checks.organization = me.organizationSlug ?? me.organizationId;
158
+ checks.scopes = me.scopes;
159
+ checks.apiVersion = me.apiVersion;
160
+ checks.contractVersion = me.contractVersion;
161
+ } catch (err) {
162
+ ok = false;
163
+ checks.error = err.message;
164
+ }
165
+ }
166
+ if (values.json) {
167
+ printJson(io, { ok, ...checks });
168
+ } else {
169
+ out(io, `API URL: ${checks.apiUrl}`);
170
+ out(
171
+ io,
172
+ `Key: ${checks.keyConfigured ? `configured (${checks.keySource})` : "MISSING - run 'index365 login'"}`,
173
+ );
174
+ if (checks.reachable) {
175
+ out(io, `Auth: ok as ${checks.keyPrefix}… (org ${checks.organization})`);
176
+ out(io, `Scopes: ${checks.scopes.join(", ")}`);
177
+ out(io, `API: ${checks.apiVersion} · contract v${checks.contractVersion}`);
178
+ } else if (checks.error) {
179
+ out(io, `Auth: FAILED - ${checks.error}`);
180
+ }
181
+ out(io, ok ? "All good." : "Doctor found problems.");
182
+ }
183
+ return ok ? EXIT.OK : EXIT.ERROR;
184
+ }
185
+
186
+ async function cmdProjects(argv, io, env) {
187
+ const [sub, ...rest] = argv;
188
+ if (sub !== "list") throw new CliError("Usage: index365 projects list", EXIT.USAGE);
189
+ const { values } = parse(rest, {
190
+ limit: { type: "string" },
191
+ cursor: { type: "string" },
192
+ });
193
+ const settings = settingsFor(values, env);
194
+ const data = await apiRequest(settings, "GET", "/api/v1/projects", {
195
+ query: { limit: values.limit, cursor: values.cursor },
196
+ fetchImpl: io.fetch,
197
+ });
198
+ if (values.json) {
199
+ printJson(io, data);
200
+ } else {
201
+ for (const project of data.projects) {
202
+ out(io, `${project.projectId} ${project.domain} (${project.name}, ${project.status})`);
203
+ }
204
+ if (data.projects.length === 0) out(io, "No projects.");
205
+ if (data.pagination.nextCursor) out(io, `More: --cursor ${data.pagination.nextCursor}`);
206
+ }
207
+ return EXIT.OK;
208
+ }
209
+
210
+ const WAIT_POLL_MS = 5_000;
211
+ const WAIT_TIMEOUT_MS = 15 * 60_000;
212
+ const TERMINAL_STATUSES = new Set(["completed", "failed", "failed_auto_credit", "refunded"]);
213
+
214
+ async function cmdRuns(argv, io, env) {
215
+ const [sub, ...rest] = argv;
216
+ if (sub === "start") {
217
+ const { values } = parse(rest, {
218
+ project: { type: "string" },
219
+ "idempotency-key": { type: "string" },
220
+ wait: { type: "boolean", default: false },
221
+ });
222
+ if (!values.project) {
223
+ throw new CliError("Usage: index365 runs start --project <projectId> [--wait]", EXIT.USAGE);
224
+ }
225
+ return startRunAndMaybeWait(values, io, env, "paid_ai_readiness");
226
+ }
227
+ if (sub === "get") {
228
+ const { values, positionals } = parse(rest);
229
+ const runId = positionals[0];
230
+ if (!runId) throw new CliError("Usage: index365 runs get <runId>", EXIT.USAGE);
231
+ const settings = settingsFor(values, env);
232
+ const run = await apiRequest(settings, "GET", `/api/v1/runs/${runId}`, {
233
+ fetchImpl: io.fetch,
234
+ });
235
+ if (values.json) printJson(io, run);
236
+ else {
237
+ out(io, `${run.runId} ${run.status} ${run.progressPct}% ${run.url}`);
238
+ if (run.score !== null) {
239
+ out(
240
+ io,
241
+ `Score ${run.score}/100 · findings ${run.findingsTotal} · ${JSON.stringify(run.severityCounts)}`,
242
+ );
243
+ }
244
+ out(io, `Human view: ${run.humanUrl}`);
245
+ }
246
+ return EXIT.OK;
247
+ }
248
+ throw new CliError("Usage: index365 runs <start|get>", EXIT.USAGE);
249
+ }
250
+
251
+ async function cmdFindings(argv, io, env) {
252
+ const [sub, ...rest] = argv;
253
+ if (sub === "list") {
254
+ const { values } = parse(rest, {
255
+ run: { type: "string" },
256
+ severity: { type: "string" },
257
+ category: { type: "string" },
258
+ limit: { type: "string" },
259
+ cursor: { type: "string" },
260
+ });
261
+ if (!values.run) throw new CliError("Usage: index365 findings list --run <runId>", EXIT.USAGE);
262
+ const settings = settingsFor(values, env);
263
+ const data = await apiRequest(settings, "GET", `/api/v1/runs/${values.run}/findings`, {
264
+ query: {
265
+ severity: values.severity,
266
+ category: values.category,
267
+ limit: values.limit,
268
+ cursor: values.cursor,
269
+ },
270
+ fetchImpl: io.fetch,
271
+ });
272
+ if (values.json) printJson(io, data);
273
+ else {
274
+ for (const finding of data.findings) {
275
+ out(
276
+ io,
277
+ `${finding.findingId} [${finding.severity}/${finding.category}] ${finding.title}`,
278
+ );
279
+ }
280
+ out(io, `${data.findings.length} of ${data.findingsTotal} findings.`);
281
+ if (data.pagination.nextCursor) out(io, `More: --cursor ${data.pagination.nextCursor}`);
282
+ }
283
+ return EXIT.OK;
284
+ }
285
+ if (sub === "get") {
286
+ const { values, positionals } = parse(rest, { run: { type: "string" } });
287
+ const findingId = positionals[0];
288
+ if (!values.run || !findingId) {
289
+ throw new CliError("Usage: index365 findings get --run <runId> <findingId>", EXIT.USAGE);
290
+ }
291
+ const settings = settingsFor(values, env);
292
+ const data = await apiRequest(
293
+ settings,
294
+ "GET",
295
+ `/api/v1/runs/${values.run}/findings/${findingId}`,
296
+ { fetchImpl: io.fetch },
297
+ );
298
+ if (values.json) printJson(io, data);
299
+ else {
300
+ const f = data.finding;
301
+ out(io, `${f.findingId} [${f.severity}/${f.category}] ${f.title}`);
302
+ out(io, f.detail);
303
+ out(io, `Fix: ${f.remediation}`);
304
+ out(io, `Affected: ${f.affectedUrls.join(", ")}`);
305
+ }
306
+ return EXIT.OK;
307
+ }
308
+ throw new CliError("Usage: index365 findings <list|get>", EXIT.USAGE);
309
+ }
310
+
311
+ async function cmdReports(argv, io, env) {
312
+ const [sub, ...rest] = argv;
313
+ if (sub === "context") {
314
+ const { values, positionals } = parse(rest);
315
+ const runId = positionals[0];
316
+ if (!runId) throw new CliError("Usage: index365 reports context <runId>", EXIT.USAGE);
317
+ const settings = settingsFor(values, env);
318
+ const data = await apiRequest(settings, "GET", `/api/v1/runs/${runId}/report`, {
319
+ fetchImpl: io.fetch,
320
+ });
321
+ printJson(io, data);
322
+ return EXIT.OK;
323
+ }
324
+ if (sub === "download") {
325
+ const { values, positionals } = parse(rest, { output: { type: "string" } });
326
+ const runId = positionals[0];
327
+ if (!runId) {
328
+ throw new CliError("Usage: index365 reports download <runId> [--output <file>]", EXIT.USAGE);
329
+ }
330
+ const settings = settingsFor(values, env);
331
+ const link = await apiRequest(settings, "GET", `/api/v1/runs/${runId}/pdf`, {
332
+ fetchImpl: io.fetch,
333
+ });
334
+ const target = values.output ?? `.index365/index365-audit-${runId}.pdf`;
335
+ const res = await io.fetch(link.url);
336
+ if (!res.ok) throw new CliError(`PDF download failed (${res.status}).`, EXIT.ERROR);
337
+ const buffer = Buffer.from(await res.arrayBuffer());
338
+ mkdirSync(dirname(target), { recursive: true });
339
+ writeFileSync(target, buffer);
340
+ if (values.json) printJson(io, { ok: true, file: target, bytes: buffer.length });
341
+ else out(io, `Saved ${target} (${buffer.length} bytes).`);
342
+ return EXIT.OK;
343
+ }
344
+ throw new CliError("Usage: index365 reports <context|download>", EXIT.USAGE);
345
+ }
346
+
347
+ /** Shared run-start + optional poll loop (AI-readiness and Marketing Signal). */
348
+ async function startRunAndMaybeWait(values, io, env, scanMode) {
349
+ const settings = settingsFor(values, env);
350
+ let run = await apiRequest(settings, "POST", "/api/v1/runs", {
351
+ body: { projectId: values.project, scanMode },
352
+ idempotencyKey: values["idempotency-key"],
353
+ fetchImpl: io.fetch,
354
+ });
355
+ if (!values.json) out(io, `Run ${run.runId} ${run.status} (${run.url}).`);
356
+
357
+ if (values.wait) {
358
+ const startedAt = io.now();
359
+ while (!TERMINAL_STATUSES.has(run.status)) {
360
+ if (io.now() - startedAt > WAIT_TIMEOUT_MS) {
361
+ throw new CliError(`Timed out waiting for run ${run.runId}.`, EXIT.ERROR);
362
+ }
363
+ await io.sleep(WAIT_POLL_MS);
364
+ run = await apiRequest(settings, "GET", `/api/v1/runs/${run.runId}`, {
365
+ fetchImpl: io.fetch,
366
+ });
367
+ if (!values.json) out(io, ` ${run.status} ${run.progressPct}% ${run.currentStep ?? ""}`);
368
+ }
369
+ }
370
+ if (values.json) printJson(io, run);
371
+ else if (values.wait) {
372
+ out(
373
+ io,
374
+ `Run ${run.runId} ${run.status}${run.score !== null ? ` - score ${run.score}/100` : ""}.`,
375
+ );
376
+ }
377
+ return run.status === "completed" || !values.wait ? EXIT.OK : EXIT.ERROR;
378
+ }
379
+
380
+ async function cmdMarketing(argv, io, env) {
381
+ const [sub, ...rest] = argv;
382
+ if (sub === "run") {
383
+ const { values } = parse(rest, {
384
+ project: { type: "string" },
385
+ "idempotency-key": { type: "string" },
386
+ wait: { type: "boolean", default: false },
387
+ });
388
+ if (!values.project) {
389
+ throw new CliError(
390
+ "Usage: index365 marketing run --project <projectId> [--wait]",
391
+ EXIT.USAGE,
392
+ );
393
+ }
394
+ return startRunAndMaybeWait(values, io, env, "paid_marketing_signal");
395
+ }
396
+ if (sub === "report") {
397
+ const { values } = parse(rest, { project: { type: "string" } });
398
+ if (!values.project) {
399
+ throw new CliError("Usage: index365 marketing report --project <projectId>", EXIT.USAGE);
400
+ }
401
+ const settings = settingsFor(values, env);
402
+ const data = await apiRequest(
403
+ settings,
404
+ "GET",
405
+ `/api/v1/projects/${values.project}/marketing/report`,
406
+ { fetchImpl: io.fetch },
407
+ );
408
+ printJson(io, data);
409
+ return EXIT.OK;
410
+ }
411
+ if (sub === "findings") {
412
+ const { values } = parse(rest, {
413
+ run: { type: "string" },
414
+ project: { type: "string" },
415
+ severity: { type: "string" },
416
+ stage: { type: "string" },
417
+ limit: { type: "string" },
418
+ cursor: { type: "string" },
419
+ });
420
+ const settings = settingsFor(values, env);
421
+ let runId = values.run;
422
+ if (!runId && values.project) {
423
+ // Resolve the latest completed marketing run through the project report.
424
+ const report = await apiRequest(
425
+ settings,
426
+ "GET",
427
+ `/api/v1/projects/${values.project}/marketing/report`,
428
+ { fetchImpl: io.fetch },
429
+ );
430
+ runId = report.runId;
431
+ }
432
+ if (!runId) {
433
+ throw new CliError(
434
+ "Usage: index365 marketing findings --run <runId> | --project <projectId>",
435
+ EXIT.USAGE,
436
+ );
437
+ }
438
+ const data = await apiRequest(settings, "GET", `/api/v1/runs/${runId}/findings`, {
439
+ query: {
440
+ severity: values.severity,
441
+ stage: values.stage,
442
+ limit: values.limit,
443
+ cursor: values.cursor,
444
+ },
445
+ fetchImpl: io.fetch,
446
+ });
447
+ if (values.json) printJson(io, data);
448
+ else {
449
+ for (const finding of data.findings) {
450
+ out(
451
+ io,
452
+ `${finding.findingId} [${finding.severity}/${finding.stage ?? finding.category}] ${finding.title}`,
453
+ );
454
+ }
455
+ out(io, `${data.findings.length} of ${data.findingsTotal} findings.`);
456
+ if (data.pagination.nextCursor) out(io, `More: --cursor ${data.pagination.nextCursor}`);
457
+ }
458
+ return EXIT.OK;
459
+ }
460
+ throw new CliError("Usage: index365 marketing <run|report|findings>", EXIT.USAGE);
461
+ }
462
+
463
+ async function cmdIntegrations(argv, io, env) {
464
+ const [sub, ...rest] = argv;
465
+ // `status` reads the same registry as `list` (per-provider phase + connection).
466
+ if (sub !== "list" && sub !== "status") {
467
+ throw new CliError(
468
+ "Usage: index365 integrations <list|status> --project <projectId>",
469
+ EXIT.USAGE,
470
+ );
471
+ }
472
+ const { values } = parse(rest, { project: { type: "string" } });
473
+ if (!values.project) {
474
+ throw new CliError(`Usage: index365 integrations ${sub} --project <projectId>`, EXIT.USAGE);
475
+ }
476
+ const settings = settingsFor(values, env);
477
+ const data = await apiRequest(
478
+ settings,
479
+ "GET",
480
+ `/api/v1/projects/${values.project}/integrations`,
481
+ { fetchImpl: io.fetch },
482
+ );
483
+ if (values.json) printJson(io, data);
484
+ else {
485
+ for (const integration of data.integrations) {
486
+ out(
487
+ io,
488
+ `${integration.provider.padEnd(20)} ${integration.connected ? "connected" : integration.phase} (${integration.access})`,
489
+ );
490
+ }
491
+ if (data.note) out(io, data.note);
492
+ }
493
+ return EXIT.OK;
494
+ }
495
+
496
+ async function cmdMcp(argv, io, env) {
497
+ const [sub, ...rest] = argv;
498
+ if (sub !== "config") throw new CliError("Usage: index365 mcp config [--json]", EXIT.USAGE);
499
+ const { values } = parse(rest);
500
+ const settings = settingsFor(values, env);
501
+ const serverConfig = {
502
+ command: "npx",
503
+ args: ["-y", "@index365/mcp"],
504
+ env: {
505
+ INDEX365_API_KEY: "<your i365_ key>",
506
+ INDEX365_API_URL: settings.apiUrl,
507
+ },
508
+ };
509
+ if (values.json) {
510
+ printJson(io, { mcpServers: { index365: serverConfig } });
511
+ return EXIT.OK;
512
+ }
513
+ out(io, "Claude Code:");
514
+ out(io, " claude mcp add index365 -e INDEX365_API_KEY=<key> -- npx -y @index365/mcp");
515
+ out(io, "");
516
+ out(io, "Codex / Cursor / any MCP host (JSON):");
517
+ out(io, JSON.stringify({ mcpServers: { index365: serverConfig } }, null, 2));
518
+ out(io, "");
519
+ out(io, "The MCP server is read-only by default and calls the same /api/v1 as this CLI.");
520
+ return EXIT.OK;
521
+ }
522
+
523
+ /**
524
+ * Run the CLI. `io` carries every side effect (stdout/stderr/fetch/sleep/now)
525
+ * so tests can drive commands hermetically.
526
+ */
527
+ export async function run(argv, io = defaultIo(), env = process.env) {
528
+ const [command, ...rest] = argv;
529
+ try {
530
+ if (!command || command === "--help" || command === "-h" || command === "help") {
531
+ out(io, HELP);
532
+ return EXIT.OK;
533
+ }
534
+ if (command === "--version" || command === "-V") {
535
+ out(io, CLI_VERSION);
536
+ return EXIT.OK;
537
+ }
538
+ switch (command) {
539
+ case "login":
540
+ return await cmdLogin(rest, io, env);
541
+ case "logout":
542
+ return await cmdLogout(rest, io, env);
543
+ case "doctor":
544
+ return await cmdDoctor(rest, io, env);
545
+ case "projects":
546
+ return await cmdProjects(rest, io, env);
547
+ case "runs":
548
+ return await cmdRuns(rest, io, env);
549
+ case "findings":
550
+ return await cmdFindings(rest, io, env);
551
+ case "reports":
552
+ return await cmdReports(rest, io, env);
553
+ case "marketing":
554
+ return await cmdMarketing(rest, io, env);
555
+ case "integrations":
556
+ return await cmdIntegrations(rest, io, env);
557
+ case "mcp":
558
+ return await cmdMcp(rest, io, env);
559
+ default:
560
+ throw new CliError(`Unknown command '${command}'. Run 'index365 --help'.`, EXIT.USAGE);
561
+ }
562
+ } catch (err) {
563
+ if (err instanceof CliError) {
564
+ io.stderr(err.message);
565
+ return err.exitCode;
566
+ }
567
+ io.stderr(`Unexpected error: ${err?.message ?? err}`);
568
+ return EXIT.ERROR;
569
+ }
570
+ }
571
+
572
+ export function defaultIo() {
573
+ return {
574
+ stdout: (line) => console.log(line),
575
+ stderr: (line) => console.error(line),
576
+ fetch: (...args) => fetch(...args),
577
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
578
+ now: () => Date.now(),
579
+ };
580
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Thin HTTP client for /api/v1 — the CLI never talks to anything else.
3
+ * Stable exit-code mapping lives here so every command behaves identically:
4
+ *
5
+ * 0 success · 1 API/server error · 2 usage error · 3 auth (401/403)
6
+ * 4 not found · 5 quota/conflict/rate (402/409/429)
7
+ */
8
+
9
+ export const EXIT = {
10
+ OK: 0,
11
+ ERROR: 1,
12
+ USAGE: 2,
13
+ AUTH: 3,
14
+ NOT_FOUND: 4,
15
+ QUOTA: 5,
16
+ };
17
+
18
+ export class CliError extends Error {
19
+ constructor(message, exitCode, detail = null) {
20
+ super(message);
21
+ this.exitCode = exitCode;
22
+ this.detail = detail;
23
+ }
24
+ }
25
+
26
+ export function exitCodeForStatus(status) {
27
+ if (status === 401 || status === 403) return EXIT.AUTH;
28
+ if (status === 404) return EXIT.NOT_FOUND;
29
+ if (status === 402 || status === 409 || status === 429) return EXIT.QUOTA;
30
+ return EXIT.ERROR;
31
+ }
32
+
33
+ /**
34
+ * Perform one API request. Returns the parsed JSON body on 2xx; throws
35
+ * CliError with a mapped exit code otherwise. `fetchImpl` is injectable for
36
+ * tests.
37
+ */
38
+ export async function apiRequest(settings, method, apiPath, options = {}) {
39
+ const { query, body, idempotencyKey, fetchImpl = fetch } = options;
40
+ if (!settings.apiKey) {
41
+ throw new CliError(
42
+ "No API key configured. Run 'index365 login' or set INDEX365_API_KEY.",
43
+ EXIT.AUTH,
44
+ );
45
+ }
46
+ const url = new URL(`${settings.apiUrl}${apiPath}`);
47
+ for (const [key, value] of Object.entries(query ?? {})) {
48
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
49
+ }
50
+
51
+ const headers = {
52
+ authorization: `Bearer ${settings.apiKey}`,
53
+ "user-agent": "index365-cli/0.1.0",
54
+ };
55
+ if (body !== undefined) headers["content-type"] = "application/json";
56
+ if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
57
+
58
+ let response;
59
+ try {
60
+ response = await fetchImpl(url, {
61
+ method,
62
+ headers,
63
+ body: body !== undefined ? JSON.stringify(body) : undefined,
64
+ });
65
+ } catch (err) {
66
+ throw new CliError(`Could not reach ${settings.apiUrl}: ${err.message}`, EXIT.ERROR);
67
+ }
68
+
69
+ if (response.status === 204) return null;
70
+ const text = await response.text();
71
+ let parsed = null;
72
+ try {
73
+ parsed = text ? JSON.parse(text) : null;
74
+ } catch {
75
+ parsed = null;
76
+ }
77
+
78
+ if (!response.ok) {
79
+ const code = parsed?.error?.code ?? `http_${response.status}`;
80
+ const message = parsed?.error?.message ?? `Request failed with status ${response.status}.`;
81
+ throw new CliError(`${code}: ${message}`, exitCodeForStatus(response.status), parsed?.error);
82
+ }
83
+ return parsed;
84
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * CLI config: ~/.config/index365/config.json, written 0600.
7
+ *
8
+ * Resolution order (env beats file, so CI never needs a config file):
9
+ * apiKey: INDEX365_API_KEY > config.apiKey
10
+ * apiUrl: INDEX365_API_URL > config.apiUrl > https://index365.co
11
+ *
12
+ * The key is never logged by any command — `doctor` prints only the prefix
13
+ * returned by /api/v1/me.
14
+ */
15
+
16
+ export const DEFAULT_API_URL = "https://index365.co";
17
+
18
+ export function configDir(env = process.env) {
19
+ const xdg = env.XDG_CONFIG_HOME;
20
+ return path.join(xdg?.trim() ? xdg : path.join(homedir(), ".config"), "index365");
21
+ }
22
+
23
+ export function configPath(env = process.env) {
24
+ return path.join(configDir(env), "config.json");
25
+ }
26
+
27
+ export function readConfigFile(env = process.env) {
28
+ try {
29
+ const raw = readFileSync(configPath(env), "utf8");
30
+ const parsed = JSON.parse(raw);
31
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ export function writeConfigFile(config, env = process.env) {
38
+ const dir = configDir(env);
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
40
+ const file = configPath(env);
41
+ writeFileSync(file, `${JSON.stringify(config, null, "\t")}\n`, { mode: 0o600 });
42
+ // mkdir/writeFile modes are ignored when the file pre-exists; enforce.
43
+ chmodSync(file, 0o600);
44
+ return file;
45
+ }
46
+
47
+ export function deleteConfigFile(env = process.env) {
48
+ try {
49
+ unlinkSync(configPath(env));
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /** Effective settings for a command run. */
57
+ export function resolveSettings(env = process.env) {
58
+ const file = readConfigFile(env);
59
+ const apiKey = (env.INDEX365_API_KEY ?? "").trim() || (file.apiKey ?? null);
60
+ const apiUrl = ((env.INDEX365_API_URL ?? "").trim() || file.apiUrl || DEFAULT_API_URL).replace(
61
+ /\/+$/,
62
+ "",
63
+ );
64
+ return { apiKey, apiUrl, source: env.INDEX365_API_KEY ? "env" : file.apiKey ? "file" : "none" };
65
+ }