gsc-axi 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 Raditya Surya
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,128 @@
1
+ <h1 align="center">gsc-axi</h1>
2
+
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/gsc-axi"><img alt="npm" src="https://img.shields.io/npm/v/gsc-axi?style=flat-square" /></a>
5
+ <a href="https://axi.md"><img alt="AXI" src="https://img.shields.io/badge/built%20with-AXI-black?style=flat-square" /></a>
6
+ </p>
7
+
8
+ <h3 align="center">Google Search Console CLI for agents.</h3>
9
+
10
+ Search performance, indexing status, and sitemaps from the shell, designed with
11
+ [AXI](https://axi.md) (Agent eXperience Interface).
12
+
13
+ Talks to the Search Console API directly. No `googleapis` dependency — the service-account
14
+ flow is an RS256 JWT, which `node:crypto` signs in a dozen lines — so the install is
15
+ [`axi-sdk-js`](https://www.npmjs.com/package/axi-sdk-js) and a TOON encoder.
16
+
17
+ ## Why
18
+
19
+ Search Console has no CLI. Agents reach for the API and re-derive the same three things every
20
+ time: minting a token, guessing which property string is verified, and remembering that the
21
+ data lags.
22
+
23
+ - **Windows that end today show a decline that isn't real.** Search Console finalises data on
24
+ a 2-3 day delay. Every named `--range` here ends where the data is final.
25
+ - **`https://example.com/` and `sc-domain:example.com` are different properties.** Passing the
26
+ wrong one returns 404 with no hint. `gsc-axi` accepts a bare domain, resolves it to whichever
27
+ is verified, and refuses to guess when both are.
28
+ - **A 403 is almost never the credentials.** It is the service account's `client_email` not
29
+ being a user on the property. The error says that instead of sending you back to Google Cloud.
30
+
31
+ ## Quick Start
32
+
33
+ ```sh
34
+ npx skills add radityasurya/gsc-axi --skill gsc-axi -g
35
+ ```
36
+
37
+ Then give it credentials. A **service account** is the headless path:
38
+
39
+ 1. Google Cloud → create a service account → download its JSON key
40
+ 2. Enable the **Google Search Console API** for that project
41
+ 3. In Search Console → Settings → Users and permissions → add the service account's
42
+ `client_email` as a user (its own address, not yours)
43
+
44
+ ```sh
45
+ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
46
+ export GSC_SITE=https://example.com/ # optional when the account sees one property
47
+ ```
48
+
49
+ An OAuth refresh token works too, if you already have one:
50
+
51
+ ```sh
52
+ export GSC_CLIENT_ID=... GSC_CLIENT_SECRET=... GSC_REFRESH_TOKEN=...
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ gsc-axi # dashboard — traffic, change, top queries
59
+ gsc-axi sites # properties and permission level
60
+
61
+ gsc-axi performance # top queries
62
+ gsc-axi performance --by page --range 90d
63
+ gsc-axi performance --by country --limit 10
64
+ gsc-axi performance --by query --contains pricing --device mobile
65
+
66
+ gsc-axi compare # vs the preceding window
67
+ gsc-axi compare --by query # which queries moved, ranked by swing
68
+
69
+ gsc-axi opportunities # queries ranking 4-20
70
+ gsc-axi opportunities --min-impressions 200 --range 90d
71
+
72
+ gsc-axi inspect https://example.com/blog/post # indexed? canonical? last crawl?
73
+ gsc-axi sitemaps # submitted, last read, errors
74
+ gsc-axi sitemaps submit https://example.com/sitemap.xml
75
+ ```
76
+
77
+ ### Commands
78
+
79
+ | Command | Purpose |
80
+ | --- | --- |
81
+ | *(none)* | Dashboard: clicks, impressions, CTR, position, change, top queries |
82
+ | `sites` | Properties this account can reach |
83
+ | `performance` | Break traffic down by query, page, country, device, date, or appearance |
84
+ | `compare` | This window against the one before it |
85
+ | `opportunities` | Queries ranking 4-20 with real volume |
86
+ | `inspect` | Index status, canonical, crawl time, mobile and rich-result verdicts |
87
+ | `sitemaps` | `list`, `submit` |
88
+ | `setup` | Agent session integration: `hooks`, `status`, `uninstall` |
89
+
90
+ ### Windows
91
+
92
+ `--range` takes `7d`, `28d` (default), `90d`, `6m`, `12m`, `16m` — Search Console keeps 16
93
+ months and nothing older. `--start` / `--end` take `YYYY-MM-DD` and override the range.
94
+
95
+ ## Behaviour worth relying on
96
+
97
+ - **Reads, plus sitemap submission.** It never deletes a sitemap or removes a property.
98
+ - **`sitemaps submit` is idempotent** — an already-submitted sitemap reports `unchanged`.
99
+ - **Refuses to guess the property** when the account sees several and none was named.
100
+ - **CTR and position are formatted** — `3.5%` and `8.4`, not `0.0352941` and `8.43219`.
101
+ - **Fails loud.** Unknown flags, ranges, and dimensions exit 2 and name the valid values.
102
+ - **TOON output** on stdout, structured errors on stdout too, diagnostics on stderr.
103
+
104
+ ## Relationship to openpanel-axi
105
+
106
+ [`openpanel-axi`](https://github.com/radityasurya/openpanel-axi) also has a `gsc` command. It
107
+ reads Search Console data **that OpenPanel has synced**, which requires connecting GSC to
108
+ OpenPanel and is limited to what OpenPanel stores. `gsc-axi` talks to Google directly, needs
109
+ no OpenPanel, and covers what OpenPanel does not: URL inspection, sitemaps, arbitrary
110
+ dimension and filter combinations, and properties you do not track in OpenPanel at all.
111
+
112
+ ## Development
113
+
114
+ ```sh
115
+ npm install
116
+ npm test # node:test, no framework, no network
117
+ npm run build:skill
118
+ node bin/gsc-axi.js --help
119
+ ```
120
+
121
+ The suite stubs `fetch` with a route table standing in for Google, and generates a real RSA
122
+ key so the JWT signing path is exercised rather than mocked. No Google account required.
123
+
124
+ See [AGENTS.md](AGENTS.md) for architecture notes and [VISION.md](VISION.md) for scope.
125
+
126
+ ## License
127
+
128
+ MIT
package/bin/gsc-axi.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { tryFastPath } from "axi-sdk-js/fast-path";
3
+ import { VERSION } from "../src/version.js";
4
+
5
+ if (!tryFastPath(process.argv.slice(2), { version: VERSION })) {
6
+ const { main } = await import("../src/cli.js");
7
+ await main();
8
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "gsc-axi",
3
+ "version": "0.1.0",
4
+ "description": "AXI-compliant Google Search Console CLI — search performance, URL inspection, and sitemaps with token-efficient TOON output",
5
+ "keywords": ["axi", "google-search-console", "gsc", "seo", "agent", "cli", "toon"],
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/radityasurya/gsc-axi.git"
9
+ },
10
+ "homepage": "https://github.com/radityasurya/gsc-axi#readme",
11
+ "bugs": { "url": "https://github.com/radityasurya/gsc-axi/issues" },
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "bin": { "gsc-axi": "bin/gsc-axi.js" },
15
+ "exports": { ".": "./src/cli.js" },
16
+ "files": ["bin", "src", "skills", "README.md", "LICENSE"],
17
+ "engines": { "node": ">=20" },
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "build:skill": "node scripts/build-skill.mjs",
21
+ "check:skill": "node scripts/build-skill.mjs --check"
22
+ },
23
+ "dependencies": {
24
+ "axi-sdk-js": "^0.1.11",
25
+ "@toon-format/toon": "^0.9.0"
26
+ }
27
+ }
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: gsc-axi
3
+ description: >
4
+ Read Google Search Console through the gsc-axi CLI — search performance by query, page, country and device; period-over-period comparisons; queries ranking 4-20 worth improving; URL indexing status; and sitemaps. Use whenever a task touches SEO or Google Search: why traffic moved, which queries a page ranks for, whether a URL is indexed, or whether a sitemap was read.
5
+ user-invocable: false
6
+ metadata:
7
+ hermes:
8
+ tags: [seo, google-search-console, search, indexing, sitemaps, analytics]
9
+ ---
10
+
11
+ # gsc-axi
12
+
13
+ Run the CLI with no arguments first — it prints this month's search traffic, how it moved,
14
+ and the top queries, plus the next commands to run.
15
+
16
+ ```sh
17
+ npx -y gsc-axi
18
+ ```
19
+
20
+ Requires Google credentials with access to the property. A **service account** is the
21
+ headless path and the one to prefer:
22
+
23
+ ```sh
24
+ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
25
+ export GSC_SITE=https://example.com/ # or sc-domain:example.com; optional with one property
26
+ ```
27
+
28
+ The service account's own `client_email` must be added as a user on the property in Search
29
+ Console (Settings → Users and permissions). Adding *your* email does nothing for it.
30
+
31
+ ## Commands
32
+
33
+ ```sh
34
+ npx -y gsc-axi # dashboard: traffic, change, top queries
35
+ npx -y gsc-axi sites # properties this account can reach
36
+ npx -y gsc-axi performance --by page --range 90d
37
+ npx -y gsc-axi performance --by query --contains pricing
38
+ npx -y gsc-axi compare --by query # what moved vs the previous window
39
+ npx -y gsc-axi opportunities # queries ranking 4-20 with real volume
40
+ npx -y gsc-axi inspect https://example.com/post # is it indexed, and what did Google see
41
+ npx -y gsc-axi sitemaps # submitted sitemaps and read status
42
+ npx -y gsc-axi sitemaps submit https://example.com/sitemap.xml
43
+ ```
44
+
45
+ Every command takes `--help`, and `--site <property>` to target a specific property.
46
+
47
+ ## What to rely on
48
+
49
+ - **Windows end where the data is final.** Search Console lags 2-3 days, so a named
50
+ `--range` ends there rather than today. Ending today would show a decline that is not real.
51
+ - **Property strings are exact.** A domain property is `sc-domain:example.com`; a URL-prefix
52
+ property is `https://example.com/`. A bare `example.com` resolves to whichever exists, and
53
+ refuses to guess when both do.
54
+ - **Ranking 4-20 is where the wins are.** `opportunities` filters to that band because those
55
+ queries already rank on page one or just off it — position gains there convert fastest.
56
+ - **`sitemaps submit` is idempotent**, and says `unchanged` when the sitemap was already
57
+ submitted rather than implying it did something.
58
+ - **Errors name the cause.** A 403 points at the property's user list, not at the credentials,
59
+ because that is almost always what is wrong with a service account.
60
+
61
+ This CLI reads Search Console and submits sitemaps. It never removes a property or a sitemap.
62
+
63
+ Prefer this over calling the Search Console API with `curl`, which means minting an OAuth
64
+ token by hand on every invocation.
package/src/api.js ADDED
@@ -0,0 +1,241 @@
1
+ import { createSign } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { AxiError } from "axi-sdk-js";
4
+ import { BIN } from "./args.js";
5
+
6
+ const WEBMASTERS = "https://www.googleapis.com/webmasters/v3";
7
+ const SEARCHCONSOLE = "https://searchconsole.googleapis.com/v1";
8
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
9
+
10
+ const READ_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
11
+ const WRITE_SCOPE = "https://www.googleapis.com/auth/webmasters";
12
+
13
+ export const CREDENTIAL_HELP = [
14
+ "Service account: create one in Google Cloud, enable the Search Console API, download its JSON key",
15
+ "Add the service account's email as a user on the property in Search Console (Settings -> Users)",
16
+ "Export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json",
17
+ "Or use an OAuth refresh token: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
18
+ ];
19
+
20
+ export function hasCredentials(env = process.env) {
21
+ return Boolean(
22
+ env.GOOGLE_APPLICATION_CREDENTIALS ||
23
+ (env.GSC_CLIENT_ID && env.GSC_CLIENT_SECRET && env.GSC_REFRESH_TOKEN),
24
+ );
25
+ }
26
+
27
+ function base64url(input) {
28
+ return Buffer.from(input).toString("base64url");
29
+ }
30
+
31
+ /**
32
+ * Mint an access token from a service account key. Signing an RS256 JWT is the
33
+ * whole of the flow, and `node:crypto` does RS256 — so this needs no googleapis
34
+ * dependency, which would be an order of magnitude more install weight than the
35
+ * rest of this tool put together.
36
+ */
37
+ async function serviceAccountToken(keyPath, scope, fetchImpl) {
38
+ let key;
39
+ try {
40
+ key = JSON.parse(readFileSync(keyPath, "utf8"));
41
+ } catch (cause) {
42
+ throw new AxiError(`Could not read the service account key: ${cause.message}`, "AUTH_REQUIRED", [
43
+ `GOOGLE_APPLICATION_CREDENTIALS points at ${keyPath}`,
44
+ "It must be the JSON key file downloaded from Google Cloud",
45
+ ]);
46
+ }
47
+ if (!key.client_email || !key.private_key) {
48
+ throw new AxiError("That JSON is not a service account key", "AUTH_REQUIRED", [
49
+ "A service account key has `client_email` and `private_key` fields",
50
+ "Download it from Google Cloud -> IAM -> Service Accounts -> Keys",
51
+ ]);
52
+ }
53
+
54
+ const now = Math.floor(Date.now() / 1000);
55
+ const claim = {
56
+ iss: key.client_email,
57
+ scope,
58
+ aud: TOKEN_URL,
59
+ iat: now,
60
+ exp: now + 3600,
61
+ };
62
+ const unsigned = `${base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }))}.${base64url(JSON.stringify(claim))}`;
63
+ const signature = createSign("RSA-SHA256").update(unsigned).sign(key.private_key, "base64url");
64
+
65
+ return exchange(
66
+ { grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${unsigned}.${signature}` },
67
+ fetchImpl,
68
+ { email: key.client_email },
69
+ );
70
+ }
71
+
72
+ async function refreshTokenGrant(env, fetchImpl) {
73
+ return exchange(
74
+ {
75
+ grant_type: "refresh_token",
76
+ client_id: env.GSC_CLIENT_ID,
77
+ client_secret: env.GSC_CLIENT_SECRET,
78
+ refresh_token: env.GSC_REFRESH_TOKEN,
79
+ },
80
+ fetchImpl,
81
+ {},
82
+ );
83
+ }
84
+
85
+ async function exchange(body, fetchImpl, context) {
86
+ let response;
87
+ try {
88
+ response = await fetchImpl(TOKEN_URL, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/x-www-form-urlencoded" },
91
+ body: new URLSearchParams(body).toString(),
92
+ });
93
+ } catch (cause) {
94
+ throw new AxiError(`Could not reach Google to mint a token: ${cause.message}`, "NETWORK_ERROR", [
95
+ "Check network connectivity to oauth2.googleapis.com",
96
+ ]);
97
+ }
98
+ const payload = await response.json().catch(() => ({}));
99
+ if (!response.ok) {
100
+ const detail = payload.error_description || payload.error || `HTTP ${response.status}`;
101
+ throw new AxiError(`Google refused the credentials: ${detail}`, "AUTH_ERROR", [
102
+ ...(context.email
103
+ ? [`Check the Search Console API is enabled for the project owning ${context.email}`]
104
+ : ["Check GSC_CLIENT_ID, GSC_CLIENT_SECRET, and GSC_REFRESH_TOKEN are current"]),
105
+ "A revoked key or a disabled API both land here",
106
+ ]);
107
+ }
108
+ return payload.access_token;
109
+ }
110
+
111
+ let cached;
112
+ export async function accessToken(options = {}) {
113
+ const { env = process.env, write = false, fetchImpl = fetch } = options;
114
+ if (cached) return cached;
115
+ if (!hasCredentials(env)) {
116
+ throw new AxiError("No Google credentials in the environment", "AUTH_REQUIRED", CREDENTIAL_HELP);
117
+ }
118
+ const scope = write ? WRITE_SCOPE : READ_SCOPE;
119
+ cached = env.GOOGLE_APPLICATION_CREDENTIALS
120
+ ? await serviceAccountToken(env.GOOGLE_APPLICATION_CREDENTIALS, scope, fetchImpl)
121
+ : await refreshTokenGrant(env, fetchImpl);
122
+ return cached;
123
+ }
124
+
125
+ export function resetTokenCache() {
126
+ cached = undefined;
127
+ }
128
+
129
+ function apiError(status, payload, path) {
130
+ const error = payload?.error ?? {};
131
+ const message = error.message || `Search Console request failed (HTTP ${status})`;
132
+
133
+ if (status === 401) {
134
+ return new AxiError(message, "AUTH_ERROR", [
135
+ "The token was rejected — the key may be revoked or the API disabled",
136
+ ...CREDENTIAL_HELP.slice(0, 2),
137
+ ]);
138
+ }
139
+ if (status === 403) {
140
+ return new AxiError(message, "AUTH_ERROR", [
141
+ "This account has no access to that property",
142
+ "Add it as a user in Search Console: Settings -> Users and permissions",
143
+ `Run \`${BIN} sites\` to see the properties it can reach`,
144
+ ]);
145
+ }
146
+ if (status === 404) {
147
+ return new AxiError(message, "NOT_FOUND", [
148
+ `Run \`${BIN} sites\` to list the exact property strings`,
149
+ "A domain property is written `sc-domain:example.com`, not `https://example.com/`",
150
+ ]);
151
+ }
152
+ if (status === 429) {
153
+ return new AxiError(message, "RATE_LIMITED", [
154
+ "Search Console allows 1200 queries per minute per property; wait and retry",
155
+ ]);
156
+ }
157
+ return new AxiError(message, "API_ERROR", [`while requesting ${path}`]);
158
+ }
159
+
160
+ /** One authenticated request against the Search Console API. */
161
+ export async function gsc(path, options = {}) {
162
+ const { method = "GET", body, base = WEBMASTERS, env = process.env, fetchImpl = fetch, write = false } = options;
163
+ const token = await accessToken({ env, write, fetchImpl });
164
+
165
+ let response;
166
+ try {
167
+ response = await fetchImpl(base + path, {
168
+ method,
169
+ headers: {
170
+ authorization: `Bearer ${token}`,
171
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
172
+ },
173
+ body: body === undefined ? undefined : JSON.stringify(body),
174
+ });
175
+ } catch (cause) {
176
+ throw new AxiError(`Could not reach the Search Console API: ${cause.message}`, "NETWORK_ERROR", [
177
+ "Check network connectivity to googleapis.com",
178
+ ]);
179
+ }
180
+
181
+ if (response.status === 204) return {};
182
+ const payload = await response.json().catch(() => ({}));
183
+ if (!response.ok) throw apiError(response.status, payload, path);
184
+ return payload;
185
+ }
186
+
187
+ export const inspectionBase = SEARCHCONSOLE;
188
+
189
+ /** Property strings are URLs or `sc-domain:` prefixed, and must be encoded whole. */
190
+ export function sitePath(site, suffix = "") {
191
+ return `/sites/${encodeURIComponent(site)}${suffix}`;
192
+ }
193
+
194
+ export async function listSites(options = {}) {
195
+ const payload = await gsc("/sites", options);
196
+ return (payload.siteEntry ?? []).filter((entry) => entry.permissionLevel !== "siteUnverifiedUser");
197
+ }
198
+
199
+ /**
200
+ * Resolve the property to act on: the flag, the environment, or the only one
201
+ * the account can see. Refuses to guess between several, the way an ambiguous
202
+ * name should — acting on the wrong property is a silent wrong answer.
203
+ */
204
+ export async function resolveSite(selector, options = {}) {
205
+ const env = options.env ?? process.env;
206
+ const wanted = selector || env.GSC_SITE;
207
+ const sites = await listSites(options);
208
+
209
+ if (wanted) {
210
+ const exact = sites.find((entry) => entry.siteUrl === wanted);
211
+ if (exact) return exact.siteUrl;
212
+ // Accept `example.com` for either property shape rather than making the
213
+ // agent remember which kind was verified.
214
+ const bare = wanted.replace(/^sc-domain:/, "").replace(/^https?:\/\//, "").replace(/\/$/, "");
215
+ const near = sites.filter((entry) =>
216
+ entry.siteUrl.replace(/^sc-domain:/, "").replace(/^https?:\/\//, "").replace(/\/$/, "") === bare,
217
+ );
218
+ if (near.length === 1) return near[0].siteUrl;
219
+ if (near.length > 1) {
220
+ throw new AxiError(`${wanted} matches ${near.length} properties`, "VALIDATION_ERROR", [
221
+ "Pass the exact property string instead",
222
+ ...near.map((entry) => `Run with --site ${entry.siteUrl}`),
223
+ ]);
224
+ }
225
+ throw new AxiError(`no property matching ${wanted}`, "NOT_FOUND", [
226
+ ...sites.slice(0, 5).map((entry) => `Run with --site ${entry.siteUrl}`),
227
+ `Run \`${BIN} sites\` to see all ${sites.length}`,
228
+ ]);
229
+ }
230
+
231
+ if (sites.length === 1) return sites[0].siteUrl;
232
+ if (sites.length === 0) {
233
+ throw new AxiError("This account can not see any Search Console property", "AUTH_ERROR", [
234
+ "Add the account as a user on the property: Settings -> Users and permissions",
235
+ ]);
236
+ }
237
+ throw new AxiError(`the property is ambiguous: this account sees ${sites.length}`, "VALIDATION_ERROR", [
238
+ "Pass `--site <property>` to choose one, or set GSC_SITE",
239
+ ...sites.slice(0, 5).map((entry) => `Run with --site ${entry.siteUrl}`),
240
+ ]);
241
+ }
package/src/args.js ADDED
@@ -0,0 +1,128 @@
1
+ import { parseArgs } from "node:util";
2
+ import { AxiError } from "axi-sdk-js";
3
+
4
+ export const BIN = "gsc-axi";
5
+
6
+ // Always-allowed globals, per AXI §6: they pass on every command and are never
7
+ // reported as unknown.
8
+ const GLOBAL_FLAGS = {
9
+ site: { type: "string" },
10
+ help: { type: "boolean" },
11
+ };
12
+
13
+ const RENAMED = {
14
+ property: "--site",
15
+ domain: "--site",
16
+ url: "--site",
17
+ period: "--range",
18
+ days: "--range",
19
+ since: "--start",
20
+ until: "--end",
21
+ };
22
+
23
+ /**
24
+ * Strict parse for one subcommand. Unknown flags fail loud (exit 2) and the
25
+ * error carries that subcommand's valid flags, so the agent self-corrects in
26
+ * one turn instead of making a follow-up `--help` call.
27
+ */
28
+ export function parse(argv, { command, flags = {} }) {
29
+ const options = { ...GLOBAL_FLAGS, ...flags };
30
+ try {
31
+ const { values, positionals } = parseArgs({
32
+ args: argv,
33
+ options,
34
+ allowPositionals: true,
35
+ strict: true,
36
+ });
37
+ return { values, positionals };
38
+ } catch (error) {
39
+ throw usageError(error, command, options);
40
+ }
41
+ }
42
+
43
+ function usageError(error, command, options) {
44
+ const valid = Object.keys(options)
45
+ .map((name) => `--${name}`)
46
+ .join(", ");
47
+ const raw = String(error.message);
48
+ const unknown = raw.match(/'?(--?[\w-]+)'?/)?.[1];
49
+
50
+ if (error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
51
+ const bare = unknown?.replace(/^--?/, "");
52
+ const replacement = bare && RENAMED[bare];
53
+ return new AxiError(`unknown flag ${unknown} for \`${command}\``, "VALIDATION_ERROR", [
54
+ replacement
55
+ ? `${unknown} was renamed; use ${replacement} instead`
56
+ : `valid flags for \`${command}\`: ${valid}`,
57
+ ]);
58
+ }
59
+ if (error.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
60
+ return new AxiError(raw, "VALIDATION_ERROR", [
61
+ `Pass a value: \`${BIN} ${command} ${unknown ?? "--flag"} <value>\``,
62
+ ]);
63
+ }
64
+ return new AxiError(raw, "VALIDATION_ERROR", [`valid flags for \`${command}\`: ${valid}`]);
65
+ }
66
+
67
+ export function required(value, name, command, example) {
68
+ if (value === undefined || value === null || value === "") {
69
+ throw new AxiError(`${name} is required`, "VALIDATION_ERROR", [example]);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ /** Positive-integer flag values, so `--limit abc` fails before any API call. */
75
+ export function positiveInt(value, name, fallback) {
76
+ if (value === undefined) return fallback;
77
+ const parsed = Number(value);
78
+ if (!Number.isInteger(parsed) || parsed < 1) {
79
+ throw new AxiError(`${name} must be a positive integer`, "VALIDATION_ERROR", [
80
+ `Example: ${name} 20`,
81
+ ]);
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ /** Concise per-subcommand reference (AXI §10). */
87
+ export function helpFor({ command, description, usage, flags, examples }) {
88
+ return {
89
+ command,
90
+ description,
91
+ ...(usage ? { usage } : {}),
92
+ ...(flags && Object.keys(flags).length ? { flags } : {}),
93
+ ...(examples?.length ? { examples } : {}),
94
+ };
95
+ }
96
+
97
+ export function wantsHelp(argv) {
98
+ return argv.includes("--help");
99
+ }
100
+
101
+ /**
102
+ * Shared subcommand dispatch for grouped nouns. Flags in the first position
103
+ * fall through to the group's default subcommand, so `events --limit 5`
104
+ * behaves like `events list --limit 5`.
105
+ */
106
+ export function makeDispatcher(group, handlers, { fallback, summary }) {
107
+ const groupHelp = {
108
+ command: group,
109
+ subcommands: summary,
110
+ help: [`Run \`${BIN} ${group} <subcommand> --help\` for a subcommand reference`],
111
+ };
112
+ return async function dispatch(argv) {
113
+ const [subcommand, ...rest] = argv;
114
+ if (subcommand === undefined || subcommand.startsWith("-")) {
115
+ if (wantsHelp(argv)) return groupHelp;
116
+ if (fallback) return handlers[fallback](argv);
117
+ return groupHelp;
118
+ }
119
+ const handler = handlers[subcommand];
120
+ if (!handler) {
121
+ throw new AxiError(`unknown subcommand \`${group} ${subcommand}\``, "VALIDATION_ERROR", [
122
+ `valid subcommands: ${Object.keys(handlers).join(", ")}`,
123
+ `Run \`${BIN} ${group} --help\``,
124
+ ]);
125
+ }
126
+ return handler(rest);
127
+ };
128
+ }