axusage 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,6 +151,73 @@ Human-readable output includes:
151
151
  JSON output provides structured data for automation.
152
152
  Prometheus output emits text metrics suitable for scraping.
153
153
 
154
+ ## Serve Mode
155
+
156
+ `axusage serve` starts an HTTP server that exposes Prometheus metrics at `/metrics` for scraping, with automatic polling.
157
+
158
+ ### Usage
159
+
160
+ ```bash
161
+ # Start with defaults (port 3848, poll every 5 minutes)
162
+ axusage serve
163
+
164
+ # Custom configuration
165
+ axusage serve --port 9090 --interval 60 --service claude
166
+
167
+ # With environment variables
168
+ AXUSAGE_PORT=9090 AXUSAGE_INTERVAL=60 axusage serve
169
+ ```
170
+
171
+ ### Options
172
+
173
+ | Flag | Env Var | Default | Description |
174
+ | ---------------------- | ------------------ | ----------- | --------------------------- |
175
+ | `--port <port>` | `AXUSAGE_PORT` | `3848` | Port to listen on |
176
+ | `--host <host>` | `AXUSAGE_HOST` | `127.0.0.1` | Host to bind to |
177
+ | `--interval <seconds>` | `AXUSAGE_INTERVAL` | `300` | Polling interval in seconds |
178
+ | `--service <service>` | `AXUSAGE_SERVICE` | all | Service to monitor |
179
+
180
+ ### Endpoints
181
+
182
+ - `GET /metrics` — Prometheus text exposition (`text/plain; version=0.0.4`). Returns 503 if no data has been fetched yet.
183
+ - `GET /health` — JSON health status with version, last refresh time, tracked services, and errors.
184
+
185
+ ### Container Deployment
186
+
187
+ ```bash
188
+ # Build image
189
+ podman build -t axusage .
190
+
191
+ # Run (configure credential sources via AXUSAGE_SOURCES)
192
+ podman run -p 3848:3848 --user 1000:1000 \
193
+ -e AXUSAGE_SOURCES='{"claude":{"source":"vault","name":"claude-oauth"}}' \
194
+ -e AXVAULT_URL=http://axvault:3847 \
195
+ -e AXVAULT_API_KEY=axv_sk_... \
196
+ axusage
197
+ ```
198
+
199
+ ### Docker Compose
200
+
201
+ ```bash
202
+ cp .env.example .env
203
+ # Edit .env with credential sources and vault config
204
+ docker compose up -d --build
205
+ ```
206
+
207
+ ### Publishing
208
+
209
+ Container publishing is part of CI/CD:
210
+
211
+ - Pushes to `main` run the `Release` workflow.
212
+ - If `semantic-release` creates a new version tag, CI builds and publishes a multi-arch image to `registry.j4k.dev/axusage:<version>`.
213
+
214
+ For manual publishing:
215
+
216
+ ```bash
217
+ ./scripts/publish-image.sh --dry-run
218
+ ./scripts/publish-image.sh --version 1.0.0
219
+ ```
220
+
154
221
  ## Troubleshooting
155
222
 
156
223
  ### "Required dependency '... not found'"
package/dist/cli.js CHANGED
@@ -3,10 +3,12 @@ import { Command, Option } from "@commander-js/extra-typings";
3
3
  import packageJson from "../package.json" with { type: "json" };
4
4
  import { authSetupCommand } from "./commands/auth-setup-command.js";
5
5
  import { authStatusCommand } from "./commands/auth-status-command.js";
6
+ import { serveCommand } from "./commands/serve-command.js";
6
7
  import { usageCommand } from "./commands/usage-command.js";
7
8
  import { getCredentialSourcesPath } from "./config/credential-sources.js";
8
9
  import { getAvailableServices } from "./services/service-adapter-registry.js";
9
10
  import { configureColor } from "./utils/color.js";
11
+ import { formatRequiresHelpText } from "./utils/format-requires-help-text.js";
10
12
  import { getRootOptionsError, } from "./utils/validate-root-options.js";
11
13
  // Parse --no-color early so help/error output is consistently uncolored.
12
14
  const shouldDisableColor = process.argv.includes("--no-color");
@@ -18,6 +20,7 @@ const program = new Command()
18
20
  .showHelpAfterError("(add --help for additional information)")
19
21
  .showSuggestionAfterError()
20
22
  .helpCommand(false)
23
+ .enablePositionalOptions()
21
24
  .option("--no-color", "disable color output")
22
25
  .option("-s, --service <service>", `Service to query (${getAvailableServices().join(", ")}, all) - defaults to all`)
23
26
  .addOption(new Option("-o, --format <format>", "Output format")
@@ -25,7 +28,17 @@ const program = new Command()
25
28
  .default("text"))
26
29
  .option("--auth-setup <service>", "set up authentication for a service (directs to appropriate CLI)")
27
30
  .option("--auth-status [service]", "check authentication status for services")
28
- .addHelpText("after", () => `\nExamples:\n # Fetch usage for all services\n ${packageJson.name}\n\n # JSON output for a single service\n ${packageJson.name} --service claude --format json\n\n # TSV output for piping to cut, awk, sort\n ${packageJson.name} --format tsv | tail -n +2 | awk -F'\\t' '{print $1, $4"%"}'\n\n # Filter Prometheus metrics with standard tools\n ${packageJson.name} --format prometheus | grep axusage_utilization_percent\n\n # Check authentication status for all services\n ${packageJson.name} --auth-status\n\nSources config file: ${getCredentialSourcesPath()}\n(or set AXUSAGE_SOURCES to JSON to bypass file)\n\nRequires: claude, codex (ChatGPT), gemini, gh (Copilot) CLIs for auth\nOverride CLI paths: AXUSAGE_CLAUDE_PATH, AXUSAGE_CODEX_PATH, AXUSAGE_GEMINI_PATH, AXUSAGE_GH_PATH\n`);
31
+ .addHelpText("after", () => `\nExamples:\n # Fetch usage for all services\n ${packageJson.name}\n\n # JSON output for a single service\n ${packageJson.name} --service claude --format json\n\n # TSV output for piping to cut, awk, sort\n ${packageJson.name} --format tsv | tail -n +2 | awk -F'\\t' '{print $1, $4"%"}'\n\n # Filter Prometheus metrics with standard tools\n ${packageJson.name} --format prometheus | grep axusage_utilization_percent\n\n # Check authentication status for all services\n ${packageJson.name} --auth-status\n\nSources config file: ${getCredentialSourcesPath()}\n(or set AXUSAGE_SOURCES to JSON to bypass file)\n\n${formatRequiresHelpText()}\nOverride CLI paths: AXUSAGE_CLAUDE_PATH, AXUSAGE_CODEX_PATH, AXUSAGE_GEMINI_PATH, AXUSAGE_GH_PATH\n`);
32
+ program
33
+ .command("serve")
34
+ .description("Start HTTP server exposing Prometheus metrics at /metrics")
35
+ .option("-p, --port <port>", "Port to listen on (env: AXUSAGE_PORT)")
36
+ .option("-H, --host <host>", "Host to bind to (env: AXUSAGE_HOST)")
37
+ .option("--interval <seconds>", "Polling interval in seconds (env: AXUSAGE_INTERVAL)")
38
+ .option("-s, --service <service>", "Service to monitor (env: AXUSAGE_SERVICE, default: all)")
39
+ .action(async (options) => {
40
+ await serveCommand(options);
41
+ });
29
42
  function fail(message) {
30
43
  console.error(`Error: ${message}`);
31
44
  console.error("Try 'axusage --help' for details.");
@@ -1,6 +1,5 @@
1
- import { checkAuth } from "axauth";
1
+ import { getServiceDiagnostic } from "../services/service-diagnostics.js";
2
2
  import { SUPPORTED_SERVICES, validateService, } from "../services/supported-service.js";
3
- import { getCopilotTokenFromCustomGhPath } from "../utils/copilot-gh-token.js";
4
3
  import { chalk } from "../utils/color.js";
5
4
  export function authStatusCommand(options) {
6
5
  const servicesToCheck = options.service
@@ -9,26 +8,16 @@ export function authStatusCommand(options) {
9
8
  let hasFailures = false;
10
9
  console.log(chalk.blue("\nAuthentication Status:\n"));
11
10
  for (const service of servicesToCheck) {
12
- let result = checkAuth(service);
13
- if (service === "copilot" && !result.authenticated) {
14
- const tokenFromOverride = getCopilotTokenFromCustomGhPath();
15
- if (tokenFromOverride) {
16
- result = {
17
- ...result,
18
- authenticated: true,
19
- method: "GitHub CLI (AXUSAGE_GH_PATH)",
20
- };
21
- }
22
- }
23
- const status = result.authenticated
11
+ const diagnostic = getServiceDiagnostic(service);
12
+ const status = diagnostic.authenticated
24
13
  ? chalk.green("✓ authenticated")
25
14
  : chalk.red("✗ not authenticated");
26
- if (!result.authenticated) {
15
+ if (!diagnostic.authenticated) {
27
16
  hasFailures = true;
28
17
  }
29
18
  console.log(`${chalk.bold(service)}: ${status}`);
30
- if (result.method) {
31
- console.log(` ${chalk.dim("Method:")} ${chalk.dim(result.method)}`);
19
+ if (diagnostic.authMethod) {
20
+ console.log(` ${chalk.dim("Method:")} ${chalk.dim(diagnostic.authMethod)}`);
32
21
  }
33
22
  }
34
23
  if (hasFailures) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Serve command handler — starts an HTTP server exposing Prometheus metrics.
3
+ */
4
+ type ServeCommandOptions = {
5
+ readonly port?: string;
6
+ readonly host?: string;
7
+ readonly interval?: string;
8
+ readonly service?: string;
9
+ };
10
+ export declare function serveCommand(options: ServeCommandOptions): Promise<void>;
11
+ export {};
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Serve command handler — starts an HTTP server exposing Prometheus metrics.
3
+ */
4
+ import { getServeConfig } from "../config/serve-config.js";
5
+ import { selectServicesToQuery } from "./fetch-service-usage.js";
6
+ import { fetchServicesInParallel } from "./usage-command.js";
7
+ import { formatPrometheusMetrics } from "../utils/format-prometheus-metrics.js";
8
+ import { createServer } from "../server/server.js";
9
+ import { createHealthRouter, createMetricsRouter } from "../server/routes.js";
10
+ import { getAvailableServices } from "../services/service-adapter-registry.js";
11
+ export async function serveCommand(options) {
12
+ const config = getServeConfig(options);
13
+ const availableServices = getAvailableServices();
14
+ if (config.service !== undefined &&
15
+ config.service.toLowerCase() !== "all" &&
16
+ !availableServices.includes(config.service.toLowerCase())) {
17
+ console.error(`Unknown service "${config.service}". Supported: ${availableServices.join(", ")}.`);
18
+ if (process.exitCode === undefined)
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ const servicesToQuery = selectServicesToQuery(config.service);
23
+ // Cached state
24
+ let cachedMetrics;
25
+ let lastRefreshTime;
26
+ let lastRefreshErrors = [];
27
+ let refreshing = false;
28
+ async function refreshMetrics() {
29
+ if (refreshing)
30
+ return;
31
+ refreshing = true;
32
+ try {
33
+ const results = await fetchServicesInParallel(servicesToQuery);
34
+ const successes = [];
35
+ const errors = [];
36
+ for (const { service, result } of results) {
37
+ if (result.ok) {
38
+ successes.push(result.value);
39
+ }
40
+ else {
41
+ const statusSuffix = result.error.status === undefined
42
+ ? ""
43
+ : ` (HTTP ${String(result.error.status)})`;
44
+ errors.push(`${service}: fetch failed${statusSuffix}`);
45
+ console.error(`Warning: Failed to fetch ${service}: ${result.error.message}`);
46
+ }
47
+ }
48
+ lastRefreshErrors = errors;
49
+ lastRefreshTime = new Date();
50
+ // All services failed → clear cache so /metrics returns 503 instead of
51
+ // serving stale data that could mask outages in Prometheus alerting.
52
+ cachedMetrics =
53
+ successes.length > 0
54
+ ? await formatPrometheusMetrics(successes)
55
+ : undefined;
56
+ }
57
+ finally {
58
+ refreshing = false; // eslint-disable-line require-atomic-updates -- single-threaded guard, no race
59
+ }
60
+ }
61
+ // Initial fetch
62
+ console.error(`Fetching initial metrics for: ${servicesToQuery.join(", ")}`);
63
+ await refreshMetrics();
64
+ // Create server
65
+ const healthRouter = createHealthRouter(() => ({
66
+ lastRefreshTime,
67
+ services: servicesToQuery,
68
+ errors: lastRefreshErrors,
69
+ hasMetrics: cachedMetrics !== undefined,
70
+ }));
71
+ const metricsRouter = createMetricsRouter(() => ({
72
+ metrics: cachedMetrics,
73
+ }));
74
+ const server = createServer(config, [healthRouter, metricsRouter]);
75
+ // Graceful shutdown handler — registered before start so signals during
76
+ // startup are handled. process.once ensures at-most-one invocation per signal.
77
+ // Object wrapper lets the shutdown closure reference the interval assigned
78
+ // after server.start(), without needing a reassignable `let`.
79
+ const poll = {
80
+ intervalId: undefined,
81
+ };
82
+ const shutdown = () => {
83
+ console.error("\nShutting down...");
84
+ if (poll.intervalId !== undefined)
85
+ clearInterval(poll.intervalId);
86
+ // Force-exit if server.stop() hangs (e.g. keep-alive connections not closing)
87
+ const forceExit = setTimeout(() => {
88
+ console.error("Shutdown timed out, forcing exit");
89
+ // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
90
+ process.exit(1);
91
+ }, 5000);
92
+ forceExit.unref();
93
+ server.stop().then(() => {
94
+ clearTimeout(forceExit);
95
+ // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
96
+ process.exit(0);
97
+ }, (error) => {
98
+ clearTimeout(forceExit);
99
+ console.error("Error during shutdown:", error);
100
+ // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
101
+ process.exit(1);
102
+ });
103
+ };
104
+ process.once("SIGTERM", shutdown);
105
+ process.once("SIGINT", shutdown);
106
+ // Start server first — if this throws (e.g. EADDRINUSE), no polling interval
107
+ // is left dangling keeping the process alive.
108
+ await server.start();
109
+ // Start polling only after a successful listen.
110
+ poll.intervalId = setInterval(() => {
111
+ void refreshMetrics().catch((error) => {
112
+ console.error("Unexpected error during metrics refresh:", error);
113
+ });
114
+ }, config.intervalMs);
115
+ console.error(`Polling every ${String(config.intervalMs / 1000)}s for: ${servicesToQuery.join(", ")}`);
116
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Configuration for axusage serve mode.
3
+ *
4
+ * Priority: CLI flags > environment variables > defaults.
5
+ */
6
+ export type ServeConfig = {
7
+ readonly port: number;
8
+ readonly host: string;
9
+ readonly intervalMs: number;
10
+ readonly service: string | undefined;
11
+ };
12
+ type ServeConfigOverrides = {
13
+ readonly port?: string;
14
+ readonly host?: string;
15
+ readonly interval?: string;
16
+ readonly service?: string;
17
+ };
18
+ /** Parse serve config from environment and CLI overrides */
19
+ export declare function getServeConfig(overrides?: ServeConfigOverrides): ServeConfig;
20
+ export {};
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Configuration for axusage serve mode.
3
+ *
4
+ * Priority: CLI flags > environment variables > defaults.
5
+ */
6
+ const DEFAULT_PORT = 3848;
7
+ const DEFAULT_HOST = "127.0.0.1";
8
+ const DEFAULT_INTERVAL_SECONDS = 300; // 5 minutes
9
+ /** Parse serve config from environment and CLI overrides */
10
+ export function getServeConfig(overrides = {}) {
11
+ const port = parsePort(overrides.port ?? process.env.AXUSAGE_PORT);
12
+ const host = overrides.host || process.env.AXUSAGE_HOST || DEFAULT_HOST;
13
+ const intervalSeconds = parsePositiveInt(overrides.interval ?? process.env.AXUSAGE_INTERVAL, DEFAULT_INTERVAL_SECONDS, "AXUSAGE_INTERVAL");
14
+ const service = (overrides.service ?? process.env.AXUSAGE_SERVICE) || undefined;
15
+ return {
16
+ port,
17
+ host,
18
+ intervalMs: intervalSeconds * 1000,
19
+ service,
20
+ };
21
+ }
22
+ function parsePort(value) {
23
+ if (!value)
24
+ return DEFAULT_PORT;
25
+ const port = Number(value);
26
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
27
+ throw new Error(`Invalid port: ${value}`);
28
+ }
29
+ return port;
30
+ }
31
+ function parsePositiveInt(value, defaultValue, name) {
32
+ if (!value)
33
+ return defaultValue;
34
+ const parsed = Number(value);
35
+ if (!Number.isInteger(parsed) || parsed < 1) {
36
+ console.error(`Invalid ${name} value "${value}", using default ${String(defaultValue)}`);
37
+ return defaultValue;
38
+ }
39
+ return parsed;
40
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Route handlers for axusage serve mode.
3
+ */
4
+ import { Router } from "express";
5
+ type HealthStatus = {
6
+ readonly lastRefreshTime: Date | undefined;
7
+ readonly services: readonly string[];
8
+ readonly errors: readonly string[];
9
+ readonly hasMetrics: boolean;
10
+ };
11
+ type MetricsStatus = {
12
+ readonly metrics: string | undefined;
13
+ };
14
+ /** Create router for GET /health */
15
+ export declare function createHealthRouter(getStatus: () => HealthStatus): Router;
16
+ /** Create router for GET /metrics */
17
+ export declare function createMetricsRouter(getMetrics: () => MetricsStatus): Router;
18
+ export {};
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Route handlers for axusage serve mode.
3
+ */
4
+ import { Router } from "express";
5
+ import packageJson from "../../package.json" with { type: "json" };
6
+ /** Create router for GET /health */
7
+ export function createHealthRouter(getStatus) {
8
+ const router = Router();
9
+ router.get("/health", (_request, response) => {
10
+ const status = getStatus();
11
+ const healthy = status.hasMetrics;
12
+ response.status(healthy ? 200 : 503).json({
13
+ status: healthy ? "ok" : "degraded",
14
+ version: packageJson.version,
15
+ lastRefresh: status.lastRefreshTime?.toISOString(),
16
+ services: status.services,
17
+ errors: status.errors,
18
+ });
19
+ });
20
+ return router;
21
+ }
22
+ /** Create router for GET /metrics */
23
+ export function createMetricsRouter(getMetrics) {
24
+ const router = Router();
25
+ router.get("/metrics", (_request, response) => {
26
+ const { metrics } = getMetrics();
27
+ if (!metrics) {
28
+ response.status(503).type("text/plain").send("No data yet\n");
29
+ return;
30
+ }
31
+ response
32
+ .status(200)
33
+ .type("text/plain; version=0.0.4; charset=utf-8")
34
+ .send(metrics);
35
+ });
36
+ return router;
37
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Express HTTP server for axusage metrics.
3
+ *
4
+ * Designed for both CLI and library usage via factory pattern.
5
+ */
6
+ import { type Express, type Router } from "express";
7
+ import type { ServeConfig } from "../config/serve-config.js";
8
+ /** Server instance with lifecycle methods */
9
+ type AxusageServer = {
10
+ /** The Express application instance */
11
+ readonly app: Express;
12
+ /** Start listening on the configured host:port */
13
+ start(): Promise<void>;
14
+ /** Stop the server gracefully */
15
+ stop(): Promise<void>;
16
+ };
17
+ /** Create an axusage server instance */
18
+ export declare function createServer(config: Pick<ServeConfig, "port" | "host">, routers: Router[]): AxusageServer;
19
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Express HTTP server for axusage metrics.
3
+ *
4
+ * Designed for both CLI and library usage via factory pattern.
5
+ */
6
+ import express, {} from "express";
7
+ /** Create and configure Express application */
8
+ function createApp(routers) {
9
+ const app = express();
10
+ // Security: disable X-Powered-By header
11
+ app.disable("x-powered-by");
12
+ // Register all routers
13
+ for (const router of routers) {
14
+ app.use(router);
15
+ }
16
+ // 404 handler for unmatched routes
17
+ app.use((_request, response) => {
18
+ response.status(404).json({ error: "Not found" });
19
+ });
20
+ return app;
21
+ }
22
+ /** Create an axusage server instance */
23
+ export function createServer(config, routers) {
24
+ const app = createApp(routers);
25
+ let server;
26
+ let stopPromise;
27
+ return {
28
+ app,
29
+ start() {
30
+ if (server) {
31
+ return Promise.reject(new Error("Server is already running. Call stop() before restarting."));
32
+ }
33
+ stopPromise = undefined;
34
+ return new Promise((resolve, reject) => {
35
+ const onStartupError = (error) => {
36
+ server = undefined;
37
+ reject(error);
38
+ };
39
+ server = app.listen(config.port, config.host, () => {
40
+ server?.removeListener("error", onStartupError);
41
+ server?.on("error", (error) => {
42
+ console.error("Server error:", error);
43
+ });
44
+ console.error(`axusage listening on http://${config.host}:${String(config.port)}`);
45
+ resolve();
46
+ });
47
+ server.once("error", onStartupError);
48
+ });
49
+ },
50
+ stop() {
51
+ if (stopPromise) {
52
+ return stopPromise;
53
+ }
54
+ if (!server) {
55
+ return Promise.resolve();
56
+ }
57
+ const serverToClose = server;
58
+ server = undefined;
59
+ stopPromise = new Promise((resolve, reject) => {
60
+ serverToClose.close((error) => {
61
+ if (error) {
62
+ reject(error);
63
+ }
64
+ else {
65
+ resolve();
66
+ }
67
+ });
68
+ });
69
+ return stopPromise;
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,11 @@
1
+ import type { SupportedService } from "./supported-service.js";
2
+ type ServiceDiagnostic = {
3
+ readonly service: SupportedService;
4
+ readonly cliAvailable: boolean;
5
+ readonly cliPath: string;
6
+ readonly authenticated: boolean;
7
+ readonly authMethod: string | undefined;
8
+ };
9
+ export type { ServiceDiagnostic };
10
+ export declare function getServiceDiagnostic(service: SupportedService): ServiceDiagnostic;
11
+ export declare function getAllServiceDiagnostics(): ServiceDiagnostic[];
@@ -0,0 +1,29 @@
1
+ import { checkAuth } from "axauth";
2
+ import { checkCliDependency, getAuthCliDependency, } from "../utils/check-cli-dependency.js";
3
+ import { getCopilotTokenFromCustomGhPath } from "../utils/copilot-gh-token.js";
4
+ import { SUPPORTED_SERVICES } from "./supported-service.js";
5
+ export function getServiceDiagnostic(service) {
6
+ const dependency = getAuthCliDependency(service);
7
+ const cliResult = checkCliDependency(dependency);
8
+ let authResult = checkAuth(service);
9
+ if (service === "copilot" && !authResult.authenticated) {
10
+ const tokenFromOverride = getCopilotTokenFromCustomGhPath();
11
+ if (tokenFromOverride) {
12
+ authResult = {
13
+ ...authResult,
14
+ authenticated: true,
15
+ method: "GitHub CLI (AXUSAGE_GH_PATH)",
16
+ };
17
+ }
18
+ }
19
+ return {
20
+ service,
21
+ cliAvailable: cliResult.ok,
22
+ cliPath: cliResult.path,
23
+ authenticated: authResult.authenticated,
24
+ authMethod: authResult.method,
25
+ };
26
+ }
27
+ export function getAllServiceDiagnostics() {
28
+ return SUPPORTED_SERVICES.map((service) => getServiceDiagnostic(service));
29
+ }
@@ -0,0 +1,17 @@
1
+ type RuntimeRequirement = {
2
+ readonly label: string;
3
+ readonly status: "ok";
4
+ } | {
5
+ readonly label: string;
6
+ readonly status: "missing" | "not-authorized";
7
+ readonly fix: string;
8
+ };
9
+ export type { RuntimeRequirement };
10
+ /**
11
+ * Formats a list of runtime requirements into a compact or detailed string.
12
+ *
13
+ * All-ok → single line: `Requires: claude, codex (ChatGPT), gemini, gh (Copilot)`
14
+ * Any non-ok → multi-line with inline remediation per requirement.
15
+ */
16
+ export declare function formatRequiresSection(requirements: readonly RuntimeRequirement[]): string;
17
+ export declare function formatRequiresHelpText(): string;
@@ -0,0 +1,62 @@
1
+ import { getAllServiceDiagnostics } from "../services/service-diagnostics.js";
2
+ import { getAuthCliDependency } from "./check-cli-dependency.js";
3
+ const SERVICE_LABELS = {
4
+ claude: "claude",
5
+ codex: "codex (ChatGPT)",
6
+ gemini: "gemini",
7
+ copilot: "gh (Copilot)",
8
+ };
9
+ const AUTH_FIX_COMMANDS = {
10
+ claude: "Run: claude",
11
+ codex: "Run: codex",
12
+ gemini: "Run: gemini",
13
+ copilot: "Run: gh auth login",
14
+ };
15
+ function diagnosticToRequirement(diagnostic) {
16
+ const label = SERVICE_LABELS[diagnostic.service];
17
+ if (!diagnostic.cliAvailable) {
18
+ const dependency = getAuthCliDependency(diagnostic.service);
19
+ return {
20
+ label,
21
+ status: "missing",
22
+ fix: `Install: ${dependency.installHint}. ` +
23
+ `Or set ${dependency.envVar}=/path/to/${dependency.command}`,
24
+ };
25
+ }
26
+ if (!diagnostic.authenticated) {
27
+ return {
28
+ label,
29
+ status: "not-authorized",
30
+ fix: AUTH_FIX_COMMANDS[diagnostic.service],
31
+ };
32
+ }
33
+ return { label, status: "ok" };
34
+ }
35
+ let cachedRequirements;
36
+ function getRuntimeRequirementsStatus() {
37
+ if (cachedRequirements)
38
+ return cachedRequirements;
39
+ cachedRequirements = getAllServiceDiagnostics().map((d) => diagnosticToRequirement(d));
40
+ return cachedRequirements;
41
+ }
42
+ /**
43
+ * Formats a list of runtime requirements into a compact or detailed string.
44
+ *
45
+ * All-ok → single line: `Requires: claude, codex (ChatGPT), gemini, gh (Copilot)`
46
+ * Any non-ok → multi-line with inline remediation per requirement.
47
+ */
48
+ export function formatRequiresSection(requirements) {
49
+ if (requirements.every((r) => r.status === "ok")) {
50
+ return `Requires: ${requirements.map((r) => r.label).join(", ")}`;
51
+ }
52
+ const lines = requirements.map((r) => {
53
+ if (r.status === "ok")
54
+ return ` - ${r.label}`;
55
+ const tag = r.status === "missing" ? "MISSING" : "NOT AUTHORIZED";
56
+ return ` - ${r.label} - ${tag}! ${r.fix}`;
57
+ });
58
+ return `Requires:\n${lines.join("\n")}`;
59
+ }
60
+ export function formatRequiresHelpText() {
61
+ return formatRequiresSection(getRuntimeRequirementsStatus());
62
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "axusage",
3
3
  "author": "Łukasz Jerciński",
4
4
  "license": "MIT",
5
- "version": "3.2.0",
5
+ "version": "3.4.0",
6
6
  "description": "Monitor API usage across Claude, ChatGPT, GitHub Copilot, and Gemini from a single CLI",
7
7
  "repository": {
8
8
  "type": "git",
@@ -58,11 +58,13 @@
58
58
  "commander": "^14.0.3",
59
59
  "conf": "^15.1.0",
60
60
  "env-paths": "^4.0.0",
61
+ "express": "^5.2.1",
61
62
  "prom-client": "^15.1.3",
62
63
  "zod": "^4.3.6"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@total-typescript/ts-reset": "^0.6.1",
67
+ "@types/express": "^5.0.6",
66
68
  "@types/node": "^25.3.0",
67
69
  "@vitest/coverage-v8": "^4.0.18",
68
70
  "eslint": "^10.0.1",