@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 +21 -0
- package/README.md +46 -0
- package/bin/index365.mjs +4 -0
- package/package.json +38 -0
- package/src/cli.mjs +580 -0
- package/src/client.mjs +84 -0
- package/src/config.mjs +65 -0
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
|
package/bin/index365.mjs
ADDED
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
|
+
}
|