thecatapi-cli 0.1.1
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 +72 -0
- package/bun.lock +32 -0
- package/dist/index.js +2840 -0
- package/dist/thecatapi-cli.js +2840 -0
- package/package.json +21 -0
- package/skills/thecatapi-cli/SKILL.md +80 -0
- package/src/commands/auth.ts +53 -0
- package/src/index.ts +41 -0
- package/src/lib/auth.ts +54 -0
- package/src/lib/client.ts +107 -0
- package/src/lib/config.ts +29 -0
- package/src/lib/errors.ts +101 -0
- package/src/lib/logger.ts +41 -0
- package/src/lib/output.ts +169 -0
- package/src/resources/breeds.ts +103 -0
- package/src/resources/example.ts +125 -0
- package/src/resources/facts.ts +35 -0
- package/src/resources/favourites.ts +83 -0
- package/src/resources/images.ts +153 -0
- package/src/resources/votes.ts +88 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "thecatapi-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "CLI for the thecatapi API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"thecatapi-cli": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "bun build src/index.ts --outfile dist/index.js --target bun",
|
|
11
|
+
"dev": "bun run src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"commander": "^13.0.0",
|
|
15
|
+
"picocolors": "^1.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "latest",
|
|
19
|
+
"typescript": "^5.7.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: thecatapi-cli
|
|
3
|
+
description: "Manage thecatapi via CLI - images, breeds, favourites, votes, facts. Use when user mentions 'thecatapi' or wants to interact with the thecatapi API."
|
|
4
|
+
category: apis
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# thecatapi-cli
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
If `thecatapi-cli` is not found, install and build it:
|
|
12
|
+
```bash
|
|
13
|
+
bun --version || curl -fsSL https://bun.sh/install | bash
|
|
14
|
+
npx api2cli bundle thecatapi
|
|
15
|
+
npx api2cli link thecatapi
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`api2cli link` adds `~/.local/bin` to PATH automatically. The CLI is available in the next command.
|
|
19
|
+
|
|
20
|
+
Always use `--json` flag when calling commands programmatically.
|
|
21
|
+
|
|
22
|
+
## Authentication
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
thecatapi-cli auth set "your-api-key"
|
|
26
|
+
thecatapi-cli auth test
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Get a free API key at https://thecatapi.com/signup
|
|
30
|
+
|
|
31
|
+
## Resources
|
|
32
|
+
|
|
33
|
+
### images
|
|
34
|
+
|
|
35
|
+
| Command | Description |
|
|
36
|
+
|---------|-------------|
|
|
37
|
+
| `thecatapi-cli images search --json` | Search random cat images |
|
|
38
|
+
| `thecatapi-cli images search --limit 5 --has-breeds --size small --json` | Search with filters |
|
|
39
|
+
| `thecatapi-cli images list --json` | List your uploaded images |
|
|
40
|
+
| `thecatapi-cli images get <id> --json` | Get a specific image |
|
|
41
|
+
| `thecatapi-cli images upload <file> --json` | Upload a cat image |
|
|
42
|
+
| `thecatapi-cli images delete <id> --json` | Delete an uploaded image |
|
|
43
|
+
|
|
44
|
+
### breeds
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---------|-------------|
|
|
48
|
+
| `thecatapi-cli breeds list --json` | List all cat breeds |
|
|
49
|
+
| `thecatapi-cli breeds get <id> --json` | Get breed details (e.g. abys, beng) |
|
|
50
|
+
| `thecatapi-cli breeds search <query> --json` | Search breeds by name |
|
|
51
|
+
| `thecatapi-cli breeds facts <id> --json` | Get facts about a breed |
|
|
52
|
+
|
|
53
|
+
### favourites
|
|
54
|
+
|
|
55
|
+
| Command | Description |
|
|
56
|
+
|---------|-------------|
|
|
57
|
+
| `thecatapi-cli favourites list --json` | List your favourited images |
|
|
58
|
+
| `thecatapi-cli favourites get <id> --json` | Get a specific favourite |
|
|
59
|
+
| `thecatapi-cli favourites create --image-id <id> --json` | Add image to favourites |
|
|
60
|
+
| `thecatapi-cli favourites delete <id> --json` | Remove from favourites |
|
|
61
|
+
|
|
62
|
+
### votes
|
|
63
|
+
|
|
64
|
+
| Command | Description |
|
|
65
|
+
|---------|-------------|
|
|
66
|
+
| `thecatapi-cli votes list --json` | List your votes |
|
|
67
|
+
| `thecatapi-cli votes get <id> --json` | Get a specific vote |
|
|
68
|
+
| `thecatapi-cli votes create --image-id <id> --value 1 --json` | Vote on an image (1=up, 0=down) |
|
|
69
|
+
| `thecatapi-cli votes delete <id> --json` | Delete a vote |
|
|
70
|
+
|
|
71
|
+
### facts
|
|
72
|
+
|
|
73
|
+
| Command | Description |
|
|
74
|
+
|---------|-------------|
|
|
75
|
+
| `thecatapi-cli facts list --json` | Get random cat facts |
|
|
76
|
+
| `thecatapi-cli facts list --limit 10 --json` | Get multiple cat facts |
|
|
77
|
+
|
|
78
|
+
## Global Flags
|
|
79
|
+
|
|
80
|
+
All commands support: `--json`, `--format <text|json|csv|yaml>`, `--verbose`, `--no-color`, `--no-header`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getToken, setToken, removeToken, hasToken, maskToken } from "../lib/auth.js";
|
|
3
|
+
import { client } from "../lib/client.js";
|
|
4
|
+
import { log } from "../lib/logger.js";
|
|
5
|
+
import { handleError } from "../lib/errors.js";
|
|
6
|
+
|
|
7
|
+
export const authCommand = new Command("auth").description("Manage API authentication");
|
|
8
|
+
|
|
9
|
+
authCommand
|
|
10
|
+
.command("set")
|
|
11
|
+
.description("Save your API token")
|
|
12
|
+
.argument("<token>", "Your API token")
|
|
13
|
+
.addHelpText("after", "\nExample:\n thecatapi-cli auth set sk-abc123xyz")
|
|
14
|
+
.action((token: string) => {
|
|
15
|
+
setToken(token);
|
|
16
|
+
log.success("Token saved securely");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
authCommand
|
|
20
|
+
.command("show")
|
|
21
|
+
.description("Display current token (masked by default)")
|
|
22
|
+
.option("--raw", "Show the full unmasked token")
|
|
23
|
+
.addHelpText("after", "\nExample:\n thecatapi-cli auth show\n thecatapi-cli auth show --raw")
|
|
24
|
+
.action((opts: { raw?: boolean }) => {
|
|
25
|
+
if (!hasToken()) {
|
|
26
|
+
log.warn("No token configured. Run: thecatapi-cli auth set <token>");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const token = getToken();
|
|
30
|
+
console.log(opts.raw ? token : `Token: ${maskToken(token)}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
authCommand
|
|
34
|
+
.command("remove")
|
|
35
|
+
.description("Delete the saved token")
|
|
36
|
+
.addHelpText("after", "\nExample:\n thecatapi-cli auth remove")
|
|
37
|
+
.action(() => {
|
|
38
|
+
removeToken();
|
|
39
|
+
log.success("Token removed");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
authCommand
|
|
43
|
+
.command("test")
|
|
44
|
+
.description("Verify your token works by making a test API call")
|
|
45
|
+
.addHelpText("after", "\nExample:\n thecatapi-cli auth test")
|
|
46
|
+
.action(async () => {
|
|
47
|
+
try {
|
|
48
|
+
await client.get("/");
|
|
49
|
+
log.success("Token is valid");
|
|
50
|
+
} catch (err) {
|
|
51
|
+
handleError(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { globalFlags } from "./lib/config.js";
|
|
4
|
+
import { authCommand } from "./commands/auth.js";
|
|
5
|
+
import { imagesResource } from "./resources/images.js";
|
|
6
|
+
import { breedsResource } from "./resources/breeds.js";
|
|
7
|
+
import { favouritesResource } from "./resources/favourites.js";
|
|
8
|
+
import { votesResource } from "./resources/votes.js";
|
|
9
|
+
import { factsResource } from "./resources/facts.js";
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name("thecatapi-cli")
|
|
15
|
+
.description("CLI for The Cat API — search cat images, browse breeds, vote, and more")
|
|
16
|
+
.version("0.1.0")
|
|
17
|
+
.option("--json", "Output as JSON", false)
|
|
18
|
+
.option("--format <fmt>", "Output format: text, json, csv, yaml", "text")
|
|
19
|
+
.option("--verbose", "Enable debug logging", false)
|
|
20
|
+
.option("--no-color", "Disable colored output")
|
|
21
|
+
.option("--no-header", "Omit table/csv headers (for piping)")
|
|
22
|
+
.hook("preAction", (_thisCmd, actionCmd) => {
|
|
23
|
+
const root = actionCmd.optsWithGlobals();
|
|
24
|
+
globalFlags.json = root.json ?? false;
|
|
25
|
+
globalFlags.format = root.format ?? "text";
|
|
26
|
+
globalFlags.verbose = root.verbose ?? false;
|
|
27
|
+
globalFlags.noColor = root.color === false;
|
|
28
|
+
globalFlags.noHeader = root.header === false;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Built-in commands
|
|
32
|
+
program.addCommand(authCommand);
|
|
33
|
+
|
|
34
|
+
// Resources
|
|
35
|
+
program.addCommand(imagesResource);
|
|
36
|
+
program.addCommand(breedsResource);
|
|
37
|
+
program.addCommand(favouritesResource);
|
|
38
|
+
program.addCommand(votesResource);
|
|
39
|
+
program.addCommand(factsResource);
|
|
40
|
+
|
|
41
|
+
program.parse();
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { TOKEN_PATH, AUTH_TYPE, AUTH_HEADER, APP_CLI } from "./config.js";
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
/** Check if a token is configured */
|
|
7
|
+
export function hasToken(): boolean {
|
|
8
|
+
return existsSync(TOKEN_PATH);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read the stored token. Throws if not configured. */
|
|
12
|
+
export function getToken(): string {
|
|
13
|
+
if (!hasToken()) {
|
|
14
|
+
throw new CliError(2, "No token configured.", `Run: ${APP_CLI} auth set <token>`);
|
|
15
|
+
}
|
|
16
|
+
return readFileSync(TOKEN_PATH, "utf-8").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Save a token to disk with restricted permissions (chmod 600). */
|
|
20
|
+
export function setToken(token: string): void {
|
|
21
|
+
mkdirSync(dirname(TOKEN_PATH), { recursive: true });
|
|
22
|
+
writeFileSync(TOKEN_PATH, token.trim(), { mode: 0o600 });
|
|
23
|
+
// Ensure permissions even if file existed
|
|
24
|
+
chmodSync(TOKEN_PATH, 0o600);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Delete the stored token. */
|
|
28
|
+
export function removeToken(): void {
|
|
29
|
+
if (existsSync(TOKEN_PATH)) {
|
|
30
|
+
unlinkSync(TOKEN_PATH);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Mask a token for display: "sk-abc...wxyz" */
|
|
35
|
+
export function maskToken(token: string): string {
|
|
36
|
+
if (token.length <= 8) return "****";
|
|
37
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build the auth header based on configured auth type. */
|
|
41
|
+
export function buildAuthHeaders(): Record<string, string> {
|
|
42
|
+
const token = getToken();
|
|
43
|
+
|
|
44
|
+
switch (AUTH_TYPE) {
|
|
45
|
+
case "bearer":
|
|
46
|
+
return { [AUTH_HEADER]: `Bearer ${token}` };
|
|
47
|
+
case "api-key":
|
|
48
|
+
return { [AUTH_HEADER]: token };
|
|
49
|
+
case "basic":
|
|
50
|
+
return { Authorization: `Basic ${Buffer.from(token).toString("base64")}` };
|
|
51
|
+
default:
|
|
52
|
+
return { [AUTH_HEADER]: token };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { buildAuthHeaders } from "./auth.js";
|
|
2
|
+
import { BASE_URL } from "./config.js";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
const MAX_RETRIES = 3;
|
|
7
|
+
const RETRY_DELAYS = [1000, 2000, 4000];
|
|
8
|
+
const TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
/** HTTP methods supported by the client */
|
|
11
|
+
type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
12
|
+
|
|
13
|
+
/** Options for an API request */
|
|
14
|
+
interface RequestOptions {
|
|
15
|
+
params?: Record<string, string>;
|
|
16
|
+
body?: Record<string, unknown>;
|
|
17
|
+
timeout?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Make an authenticated API request with retry logic.
|
|
22
|
+
* Retries on 429 (rate limit) and 5xx (server errors).
|
|
23
|
+
*/
|
|
24
|
+
async function request(method: Method, path: string, opts: RequestOptions = {}): Promise<unknown> {
|
|
25
|
+
let url = `${BASE_URL}${path}`;
|
|
26
|
+
|
|
27
|
+
if (opts.params) {
|
|
28
|
+
const filtered = Object.fromEntries(
|
|
29
|
+
Object.entries(opts.params).filter(([, v]) => v !== undefined && v !== ""),
|
|
30
|
+
);
|
|
31
|
+
if (Object.keys(filtered).length > 0) {
|
|
32
|
+
url += `?${new URLSearchParams(filtered).toString()}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const headers: Record<string, string> = {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
Accept: "application/json",
|
|
39
|
+
...buildAuthHeaders(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const fetchOpts: RequestInit = {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
signal: AbortSignal.timeout(opts.timeout ?? TIMEOUT_MS),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (opts.body && method !== "GET") {
|
|
49
|
+
fetchOpts.body = JSON.stringify(opts.body);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
53
|
+
log.debug(`${method} ${url}${attempt > 0 ? ` (retry ${attempt})` : ""}`);
|
|
54
|
+
|
|
55
|
+
const res = await fetch(url, fetchOpts);
|
|
56
|
+
|
|
57
|
+
// Retry on rate limit or server error
|
|
58
|
+
if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
|
|
59
|
+
const delay = RETRY_DELAYS[attempt] ?? 4000;
|
|
60
|
+
log.warn(`${res.status} - retrying in ${delay / 1000}s...`);
|
|
61
|
+
await Bun.sleep(delay);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const data = await res.json().catch(() => null);
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const msg =
|
|
69
|
+
(data as Record<string, unknown>)?.message ??
|
|
70
|
+
((data as Record<string, Record<string, unknown>>)?.error?.message as string) ??
|
|
71
|
+
res.statusText;
|
|
72
|
+
throw new CliError(res.status, `${res.status}: ${String(msg)}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new CliError(500, "Max retries exceeded");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Typed HTTP client with convenience methods */
|
|
82
|
+
export const client = {
|
|
83
|
+
/** GET request with optional query params */
|
|
84
|
+
get(path: string, params?: Record<string, string>) {
|
|
85
|
+
return request("GET", path, { params });
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/** POST request with JSON body */
|
|
89
|
+
post(path: string, body?: Record<string, unknown>) {
|
|
90
|
+
return request("POST", path, { body });
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/** PATCH request with JSON body */
|
|
94
|
+
patch(path: string, body?: Record<string, unknown>) {
|
|
95
|
+
return request("PATCH", path, { body });
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/** PUT request with JSON body */
|
|
99
|
+
put(path: string, body?: Record<string, unknown>) {
|
|
100
|
+
return request("PUT", path, { body });
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** DELETE request */
|
|
104
|
+
delete(path: string) {
|
|
105
|
+
return request("DELETE", path);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/** Application name (replaced during api2cli create) */
|
|
5
|
+
export const APP_NAME = "thecatapi";
|
|
6
|
+
|
|
7
|
+
/** CLI binary name (replaced during api2cli create) */
|
|
8
|
+
export const APP_CLI = "thecatapi-cli";
|
|
9
|
+
|
|
10
|
+
/** API base URL (replaced during api2cli create) */
|
|
11
|
+
export const BASE_URL = "https://api.thecatapi.com/v1";
|
|
12
|
+
|
|
13
|
+
/** Auth type: bearer | api-key | basic | custom */
|
|
14
|
+
export const AUTH_TYPE = "api-key";
|
|
15
|
+
|
|
16
|
+
/** Auth header name (e.g. Authorization, X-Api-Key) */
|
|
17
|
+
export const AUTH_HEADER = "x-api-key";
|
|
18
|
+
|
|
19
|
+
/** Path to the token file for this CLI */
|
|
20
|
+
export const TOKEN_PATH = join(homedir(), ".config", "tokens", `${APP_NAME}-cli.txt`);
|
|
21
|
+
|
|
22
|
+
/** Global state for output flags (set by root command) */
|
|
23
|
+
export const globalFlags = {
|
|
24
|
+
json: false,
|
|
25
|
+
format: "text" as "text" | "json" | "csv" | "yaml",
|
|
26
|
+
verbose: false,
|
|
27
|
+
noColor: false,
|
|
28
|
+
noHeader: false,
|
|
29
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
/** Exit code constants */
|
|
4
|
+
export const EXIT = {
|
|
5
|
+
SUCCESS: 0,
|
|
6
|
+
API_ERROR: 1,
|
|
7
|
+
USAGE_ERROR: 2,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
/** Structured error for JSON output */
|
|
11
|
+
interface ErrorEnvelope {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: {
|
|
14
|
+
code: number;
|
|
15
|
+
message: string;
|
|
16
|
+
suggestion?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Typed CLI error with code and optional suggestion */
|
|
21
|
+
export class CliError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
public readonly code: number,
|
|
24
|
+
message: string,
|
|
25
|
+
public readonly suggestion?: string,
|
|
26
|
+
) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CliError";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create a structured JSON error envelope */
|
|
32
|
+
toJSON(): ErrorEnvelope {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: this.code,
|
|
37
|
+
message: this.message,
|
|
38
|
+
...(this.suggestion && { suggestion: this.suggestion }),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Suggestion map for common HTTP status codes */
|
|
45
|
+
const SUGGESTIONS: Record<number, string> = {
|
|
46
|
+
401: "Check your token: thecatapi-cli auth test",
|
|
47
|
+
403: "Insufficient permissions. Check your API token scope.",
|
|
48
|
+
404: "Resource not found. Verify the ID.",
|
|
49
|
+
429: "Rate limited. Wait a moment and try again.",
|
|
50
|
+
500: "Server error. Try again later.",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Parse an HTTP status code from an error message like "401: Unauthorized" */
|
|
54
|
+
function parseStatusCode(msg: string): number | null {
|
|
55
|
+
const match = msg.match(/^(\d{3}):\s/);
|
|
56
|
+
return match ? Number(match[1]) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Handle errors consistently. Outputs JSON or human-readable
|
|
61
|
+
* depending on the --json flag, then exits with proper code.
|
|
62
|
+
*/
|
|
63
|
+
export function handleError(err: unknown, json = false): never {
|
|
64
|
+
if (err instanceof CliError) {
|
|
65
|
+
if (json) {
|
|
66
|
+
console.error(JSON.stringify(err.toJSON(), null, 2));
|
|
67
|
+
} else {
|
|
68
|
+
console.error(`${pc.red("Error")} ${err.code}: ${err.message}`);
|
|
69
|
+
if (err.suggestion) {
|
|
70
|
+
console.error(`${pc.dim("Suggestion:")} ${err.suggestion}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
process.exit(err.code >= 400 ? EXIT.API_ERROR : EXIT.USAGE_ERROR);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (err instanceof Error) {
|
|
77
|
+
const status = parseStatusCode(err.message);
|
|
78
|
+
const suggestion = status ? SUGGESTIONS[status] : undefined;
|
|
79
|
+
|
|
80
|
+
if (json) {
|
|
81
|
+
const envelope: ErrorEnvelope = {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: {
|
|
84
|
+
code: status ?? 1,
|
|
85
|
+
message: err.message,
|
|
86
|
+
...(suggestion && { suggestion }),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
console.error(JSON.stringify(envelope, null, 2));
|
|
90
|
+
} else {
|
|
91
|
+
console.error(`${pc.red("Error")}: ${err.message}`);
|
|
92
|
+
if (suggestion) {
|
|
93
|
+
console.error(`${pc.dim("Suggestion:")} ${suggestion}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
process.exit(EXIT.API_ERROR);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.error(`${pc.red("Error")}: Unknown error`);
|
|
100
|
+
process.exit(EXIT.API_ERROR);
|
|
101
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { globalFlags } from "./config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Logger that respects --json (silent except errors)
|
|
6
|
+
* and --verbose (enables debug output).
|
|
7
|
+
*/
|
|
8
|
+
export const log = {
|
|
9
|
+
/** Informational message. Suppressed in --json mode. */
|
|
10
|
+
info(msg: string): void {
|
|
11
|
+
if (!globalFlags.json) {
|
|
12
|
+
console.log(msg);
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
/** Success message with green checkmark. Suppressed in --json mode. */
|
|
17
|
+
success(msg: string): void {
|
|
18
|
+
if (!globalFlags.json) {
|
|
19
|
+
console.log(`${pc.green("✓")} ${msg}`);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/** Warning message. Suppressed in --json mode. */
|
|
24
|
+
warn(msg: string): void {
|
|
25
|
+
if (!globalFlags.json) {
|
|
26
|
+
console.warn(`${pc.yellow("⚠")} ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/** Error message. Always shown. */
|
|
31
|
+
error(msg: string): void {
|
|
32
|
+
console.error(`${pc.red("✗")} ${msg}`);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/** Debug message. Only shown with --verbose, suppressed in --json mode. */
|
|
36
|
+
debug(msg: string): void {
|
|
37
|
+
if (globalFlags.verbose && !globalFlags.json) {
|
|
38
|
+
console.log(`${pc.dim("[debug]")} ${msg}`);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|