@readwise/cli 0.3.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.
@@ -0,0 +1,2 @@
1
+ import type { ToolDef } from "../config.js";
2
+ export declare function startTui(tools: ToolDef[], token: string, authType: "oauth" | "token"): Promise<void>;
@@ -0,0 +1,11 @@
1
+ import { enterFullScreen, exitFullScreen } from "./term.js";
2
+ import { runApp } from "./app.js";
3
+ export async function startTui(tools, token, authType) {
4
+ enterFullScreen();
5
+ try {
6
+ await runApp(tools);
7
+ }
8
+ finally {
9
+ exitFullScreen();
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ export declare const LOGO: string[];
@@ -0,0 +1,16 @@
1
+ export const LOGO = [
2
+ "╔════════════════════════╗",
3
+ "║░░░░░░░░░░░░░░░░░░░░░░░░║",
4
+ "║░░░░░░░░░░░░░░░░░░░░░░░░║",
5
+ "║░░░░░▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░║",
6
+ "║░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░║",
7
+ "║░░░░░░▓▓▓▓▓▓░░▓▓▓▓▓░░░░░║",
8
+ "║░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░║",
9
+ "║░░░░░░▓▓▓▓▓▓▓▓▓▓░░░░░░░░║",
10
+ "║░░░░░░▓▓▓▓▓░▓▓▓▓░░░░░░░░║",
11
+ "║░░░░░░▓▓▓▓▓░░▓▓▓▓▓░░░░░░║",
12
+ "║░░░░░▓▓▓▓▓▓▓░░▓▓▓▓▓▓░░░░║",
13
+ "║░░░░░░░░░░░░░░░░░░░░░░░░║",
14
+ "║░░░░░░░░░░░░░░░░░░░░░░░░║",
15
+ "╚════════════════════════╝",
16
+ ];
@@ -0,0 +1,32 @@
1
+ export declare const style: {
2
+ bold: (s: string) => string;
3
+ dim: (s: string) => string;
4
+ inverse: (s: string) => string;
5
+ yellow: (s: string) => string;
6
+ red: (s: string) => string;
7
+ green: (s: string) => string;
8
+ cyan: (s: string) => string;
9
+ boldYellow: (s: string) => string;
10
+ blue: (s: string) => string;
11
+ };
12
+ export declare function enterFullScreen(): void;
13
+ export declare function exitFullScreen(): void;
14
+ /** Paint lines to terminal without flicker: cursor home → overwrite each line → clear remainder */
15
+ export declare function paint(lines: string[]): void;
16
+ export declare function screenSize(): {
17
+ cols: number;
18
+ rows: number;
19
+ };
20
+ export interface KeyEvent {
21
+ raw: string;
22
+ name: string;
23
+ shift: boolean;
24
+ ctrl: boolean;
25
+ }
26
+ export declare function parseKey(data: Buffer): KeyEvent;
27
+ /** Strip ANSI escape codes to get visible character count */
28
+ export declare function stripAnsi(s: string): string;
29
+ /** Skip `offset` visible characters, preserving ANSI state, then return the rest */
30
+ export declare function ansiSlice(s: string, offset: number): string;
31
+ /** Pad/truncate a string to a visible width (ANSI-aware) */
32
+ export declare function fitWidth(s: string, width: number): string;
@@ -0,0 +1,147 @@
1
+ // Low-level terminal utilities for flicker-free full-screen rendering.
2
+ // Instead of clearing and rewriting (which blinks), we position the cursor
3
+ // at home and overwrite in-place — content is never erased before being replaced.
4
+ const ESC = "\x1b";
5
+ // --- ANSI helpers ---
6
+ export const style = {
7
+ bold: (s) => `${ESC}[1m${s}${ESC}[22m`,
8
+ dim: (s) => `${ESC}[2m${s}${ESC}[22m`,
9
+ inverse: (s) => `${ESC}[7m${s}${ESC}[27m`,
10
+ yellow: (s) => `${ESC}[33m${s}${ESC}[39m`,
11
+ red: (s) => `${ESC}[31m${s}${ESC}[39m`,
12
+ green: (s) => `${ESC}[32m${s}${ESC}[39m`,
13
+ cyan: (s) => `${ESC}[36m${s}${ESC}[39m`,
14
+ boldYellow: (s) => `${ESC}[1;33m${s}${ESC}[22;39m`,
15
+ blue: (s) => `${ESC}[38;2;60;110;253m${s}${ESC}[39m`,
16
+ };
17
+ // --- Screen control ---
18
+ export function enterFullScreen() {
19
+ process.stdout.write(`${ESC}[?1049h`); // alternate screen buffer
20
+ process.stdout.write(`${ESC}[?25l`); // hide cursor
21
+ process.stdout.write(`${ESC}[H`); // cursor home
22
+ }
23
+ export function exitFullScreen() {
24
+ process.stdout.write(`${ESC}[?25h`); // show cursor
25
+ process.stdout.write(`${ESC}[?1049l`); // restore screen buffer
26
+ }
27
+ /** Paint lines to terminal without flicker: cursor home → overwrite each line → clear remainder */
28
+ export function paint(lines) {
29
+ const rows = process.stdout.rows ?? 24;
30
+ let out = `${ESC}[H`; // cursor home
31
+ const count = Math.min(lines.length, rows);
32
+ for (let i = 0; i < count; i++) {
33
+ out += lines[i] + `${ESC}[K\n`; // line content + clear to end of line
34
+ }
35
+ // Clear any remaining lines below content
36
+ if (count < rows) {
37
+ out += `${ESC}[J`; // clear from cursor to end of screen
38
+ }
39
+ process.stdout.write(out);
40
+ }
41
+ export function screenSize() {
42
+ return { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 };
43
+ }
44
+ export function parseKey(data) {
45
+ const s = data.toString("utf-8");
46
+ const ctrl = s.length === 1 && s.charCodeAt(0) < 32;
47
+ // Escape sequences
48
+ if (s === `${ESC}[A`)
49
+ return { raw: s, name: "up", shift: false, ctrl: false };
50
+ if (s === `${ESC}[B`)
51
+ return { raw: s, name: "down", shift: false, ctrl: false };
52
+ if (s === `${ESC}[C`)
53
+ return { raw: s, name: "right", shift: false, ctrl: false };
54
+ if (s === `${ESC}[D`)
55
+ return { raw: s, name: "left", shift: false, ctrl: false };
56
+ if (s === `${ESC}[5~`)
57
+ return { raw: s, name: "pageup", shift: false, ctrl: false };
58
+ if (s === `${ESC}[6~`)
59
+ return { raw: s, name: "pagedown", shift: false, ctrl: false };
60
+ if (s === `${ESC}[Z`)
61
+ return { raw: s, name: "tab", shift: true, ctrl: false };
62
+ if (s === ESC || s === `${ESC}${ESC}`)
63
+ return { raw: s, name: "escape", shift: false, ctrl: false };
64
+ // Shift+Enter sequences
65
+ if (s === `${ESC}[13;2u`)
66
+ return { raw: s, name: "return", shift: true, ctrl: false }; // CSI u / kitty
67
+ if (s === `${ESC}[27;2;13~`)
68
+ return { raw: s, name: "return", shift: true, ctrl: false }; // xterm
69
+ if (s === `${ESC}OM`)
70
+ return { raw: s, name: "return", shift: true, ctrl: false }; // misc terminals
71
+ // Single characters
72
+ if (s === "\r" || s === "\n")
73
+ return { raw: s, name: "return", shift: false, ctrl: false };
74
+ if (s === "\t")
75
+ return { raw: s, name: "tab", shift: false, ctrl: false };
76
+ if (s === "\x7f" || s === "\b")
77
+ return { raw: s, name: "backspace", shift: false, ctrl: false };
78
+ if (s === "\x03")
79
+ return { raw: s, name: "c", shift: false, ctrl: true }; // Ctrl+C
80
+ if (s === "\x04")
81
+ return { raw: s, name: "d", shift: false, ctrl: true }; // Ctrl+D
82
+ if (ctrl) {
83
+ return { raw: s, name: String.fromCharCode(s.charCodeAt(0) + 96), shift: false, ctrl: true };
84
+ }
85
+ return { raw: s, name: s, shift: false, ctrl: false };
86
+ }
87
+ /** Strip ANSI escape codes to get visible character count */
88
+ export function stripAnsi(s) {
89
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
90
+ }
91
+ /** Skip `offset` visible characters, preserving ANSI state, then return the rest */
92
+ export function ansiSlice(s, offset) {
93
+ if (offset <= 0)
94
+ return s;
95
+ // Collect ANSI sequences encountered while skipping so we can replay them
96
+ let activeAnsi = "";
97
+ let count = 0;
98
+ let i = 0;
99
+ while (i < s.length && count < offset) {
100
+ if (s[i] === "\x1b") {
101
+ const end = s.indexOf("m", i);
102
+ if (end >= 0) {
103
+ activeAnsi += s.slice(i, end + 1);
104
+ i = end + 1;
105
+ continue;
106
+ }
107
+ }
108
+ count++;
109
+ i++;
110
+ }
111
+ // Consume any ANSI codes right at the boundary
112
+ while (i < s.length && s[i] === "\x1b") {
113
+ const end = s.indexOf("m", i);
114
+ if (end >= 0) {
115
+ activeAnsi += s.slice(i, end + 1);
116
+ i = end + 1;
117
+ }
118
+ else
119
+ break;
120
+ }
121
+ return activeAnsi + s.slice(i);
122
+ }
123
+ /** Pad/truncate a string to a visible width (ANSI-aware) */
124
+ export function fitWidth(s, width) {
125
+ const visible = stripAnsi(s);
126
+ if (visible.length >= width) {
127
+ // Truncate — need to be careful with ANSI codes
128
+ let count = 0;
129
+ let i = 0;
130
+ while (i < s.length && count < width) {
131
+ if (s[i] === "\x1b") {
132
+ const end = s.indexOf("m", i);
133
+ if (end >= 0) {
134
+ i = end + 1;
135
+ continue;
136
+ }
137
+ }
138
+ count++;
139
+ i++;
140
+ }
141
+ // Include any trailing ANSI reset codes
142
+ const rest = s.slice(i);
143
+ const resets = rest.match(/^(\x1b\[[0-9;]*m)*/)?.[0] || "";
144
+ return s.slice(0, i) + resets;
145
+ }
146
+ return s + " ".repeat(width - visible.length);
147
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION: string;
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ const pkg = require("../package.json");
4
+ export const VERSION = pkg.version;
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@readwise/cli",
3
+ "version": "0.3.0",
4
+ "description": "Command-line interface for Readwise and Reader",
5
+ "type": "module",
6
+ "bin": {
7
+ "readwise": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "src",
12
+ "README.md",
13
+ "tsconfig.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsx src/index.ts",
18
+ "start": "node dist/index.js"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "latest",
22
+ "commander": "^13",
23
+ "open": "^10"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5",
27
+ "@types/node": "^22",
28
+ "tsx": "^4"
29
+ }
30
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { URL } from "node:url";
4
+ import open from "open";
5
+ import { loadConfig, saveConfig, type Config } from "./config.js";
6
+
7
+ const DISCOVERY_URL = "https://readwise.io/o/.well-known/oauth-authorization-server";
8
+ const REDIRECT_URI = "http://localhost:6274/callback";
9
+ const SCOPES = "openid read write";
10
+
11
+ interface OAuthMetadata {
12
+ authorization_endpoint: string;
13
+ token_endpoint: string;
14
+ registration_endpoint: string;
15
+ }
16
+
17
+ async function discover(): Promise<OAuthMetadata> {
18
+ const res = await fetch(DISCOVERY_URL);
19
+ if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status} ${res.statusText}`);
20
+ return (await res.json()) as OAuthMetadata;
21
+ }
22
+
23
+ async function registerClient(registrationEndpoint: string): Promise<{ client_id: string; client_secret: string }> {
24
+ const res = await fetch(registrationEndpoint, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({
28
+ client_name: "readwise-cli",
29
+ redirect_uris: [REDIRECT_URI],
30
+ grant_types: ["authorization_code", "refresh_token"],
31
+ token_endpoint_auth_method: "client_secret_basic",
32
+ }),
33
+ });
34
+ if (!res.ok) {
35
+ const body = await res.text();
36
+ throw new Error(`Client registration failed: ${res.status} ${body}`);
37
+ }
38
+ return (await res.json()) as { client_id: string; client_secret: string };
39
+ }
40
+
41
+ function generatePKCE(): { verifier: string; challenge: string } {
42
+ const verifier = randomBytes(48).toString("base64url");
43
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
44
+ return { verifier, challenge };
45
+ }
46
+
47
+ function waitForCallback(state: string): Promise<string> {
48
+ return new Promise((resolve, reject) => {
49
+ let timeout: NodeJS.Timeout;
50
+
51
+ const cleanup = () => {
52
+ clearTimeout(timeout);
53
+ server.close();
54
+ };
55
+
56
+ const server = createServer((req, res) => {
57
+ const url = new URL(req.url!, `http://localhost:6274`);
58
+ if (url.pathname !== "/callback") {
59
+ res.writeHead(404);
60
+ res.end();
61
+ return;
62
+ }
63
+
64
+ const code = url.searchParams.get("code");
65
+ const returnedState = url.searchParams.get("state");
66
+ const error = url.searchParams.get("error");
67
+
68
+ if (error) {
69
+ res.writeHead(200, { "Content-Type": "text/html" });
70
+ res.end(`<html><body><h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p></body></html>`);
71
+ cleanup();
72
+ reject(new Error(`OAuth error: ${error}`));
73
+ return;
74
+ }
75
+
76
+ if (!code || returnedState !== state) {
77
+ res.writeHead(400, { "Content-Type": "text/html" });
78
+ res.end(`<html><body><h1>Invalid callback</h1><p>You can close this tab.</p></body></html>`);
79
+ cleanup();
80
+ reject(new Error("Invalid callback: missing code or state mismatch"));
81
+ return;
82
+ }
83
+
84
+ res.writeHead(200, { "Content-Type": "text/html" });
85
+ res.end(`<html><body><h1>Login successful!</h1><p>You can close this tab and return to the terminal.</p></body></html>`);
86
+ cleanup();
87
+ resolve(code);
88
+ });
89
+
90
+ server.listen(6274, () => {
91
+ // Server ready
92
+ });
93
+
94
+ server.on("error", (err) => {
95
+ clearTimeout(timeout);
96
+ reject(new Error(`Failed to start callback server: ${err.message}`));
97
+ });
98
+
99
+ // Timeout after 2 minutes
100
+ timeout = setTimeout(() => {
101
+ server.close();
102
+ reject(new Error("Login timed out — no callback received within 2 minutes"));
103
+ }, 120_000);
104
+ });
105
+ }
106
+
107
+ async function exchangeToken(
108
+ tokenEndpoint: string,
109
+ code: string,
110
+ clientId: string,
111
+ clientSecret: string,
112
+ codeVerifier: string,
113
+ ): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
114
+ const res = await fetch(tokenEndpoint, {
115
+ method: "POST",
116
+ headers: {
117
+ "Content-Type": "application/x-www-form-urlencoded",
118
+ Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
119
+ },
120
+ body: new URLSearchParams({
121
+ grant_type: "authorization_code",
122
+ code,
123
+ redirect_uri: REDIRECT_URI,
124
+ code_verifier: codeVerifier,
125
+ }),
126
+ });
127
+ if (!res.ok) {
128
+ const body = await res.text();
129
+ throw new Error(`Token exchange failed: ${res.status} ${body}`);
130
+ }
131
+ return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
132
+ }
133
+
134
+ export async function login(): Promise<void> {
135
+ console.log("Discovering OAuth endpoints...");
136
+ const metadata = await discover();
137
+
138
+ let config = await loadConfig();
139
+
140
+ // Register client if needed
141
+ if (!config.client_id || !config.client_secret) {
142
+ console.log("Registering client...");
143
+ const { client_id, client_secret } = await registerClient(metadata.registration_endpoint);
144
+ config.client_id = client_id;
145
+ config.client_secret = client_secret;
146
+ await saveConfig(config);
147
+ }
148
+
149
+ const { verifier, challenge } = generatePKCE();
150
+ const state = randomBytes(16).toString("hex");
151
+
152
+ const authUrl = new URL(metadata.authorization_endpoint);
153
+ authUrl.searchParams.set("response_type", "code");
154
+ authUrl.searchParams.set("client_id", config.client_id);
155
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
156
+ authUrl.searchParams.set("scope", SCOPES);
157
+ authUrl.searchParams.set("code_challenge", challenge);
158
+ authUrl.searchParams.set("code_challenge_method", "S256");
159
+ authUrl.searchParams.set("state", state);
160
+
161
+ // Start callback server before opening browser
162
+ const codePromise = waitForCallback(state);
163
+
164
+ console.log("Opening browser for authentication...");
165
+ await open(authUrl.toString());
166
+
167
+ const code = await codePromise;
168
+
169
+ console.log("Exchanging authorization code for tokens...");
170
+ const tokens = await exchangeToken(
171
+ metadata.token_endpoint,
172
+ code,
173
+ config.client_id,
174
+ config.client_secret!,
175
+ verifier,
176
+ );
177
+
178
+ config.access_token = tokens.access_token;
179
+ config.refresh_token = tokens.refresh_token;
180
+ config.expires_at = Date.now() + tokens.expires_in * 1000;
181
+ config.auth_type = "oauth";
182
+ await saveConfig(config);
183
+
184
+ console.log("Login successful! Tokens saved to ~/.readwise-cli.json");
185
+ }
186
+
187
+ export async function loginWithToken(token: string): Promise<void> {
188
+ const config = await loadConfig();
189
+ config.access_token = token;
190
+ config.auth_type = "token";
191
+ delete config.refresh_token;
192
+ delete config.expires_at;
193
+ delete config.client_id;
194
+ delete config.client_secret;
195
+ await saveConfig(config);
196
+
197
+ console.log("Token saved to ~/.readwise-cli.json");
198
+ }
199
+
200
+ export async function ensureValidToken(): Promise<{ token: string; authType: "oauth" | "token" }> {
201
+ const config = await loadConfig();
202
+
203
+ if (!config.access_token) {
204
+ throw new Error("Not logged in. Run `readwise-cli login` or `readwise-cli login-with-token <token>` first.");
205
+ }
206
+
207
+ const authType = config.auth_type ?? "oauth";
208
+
209
+ // Access tokens don't expire and don't need refresh
210
+ if (authType === "token") {
211
+ return { token: config.access_token, authType };
212
+ }
213
+
214
+ // Refresh if expired or expiring within 60s
215
+ if (config.expires_at && Date.now() > config.expires_at - 60_000) {
216
+ if (!config.refresh_token || !config.client_id || !config.client_secret) {
217
+ throw new Error("Cannot refresh token — missing credentials. Run `readwise-cli login` again.");
218
+ }
219
+
220
+ console.error("Refreshing access token...");
221
+ const metadata = await discover();
222
+
223
+ const res = await fetch(metadata.token_endpoint, {
224
+ method: "POST",
225
+ headers: {
226
+ "Content-Type": "application/x-www-form-urlencoded",
227
+ Authorization: `Basic ${Buffer.from(`${config.client_id}:${config.client_secret}`).toString("base64")}`,
228
+ },
229
+ body: new URLSearchParams({
230
+ grant_type: "refresh_token",
231
+ refresh_token: config.refresh_token,
232
+ }),
233
+ });
234
+
235
+ if (!res.ok) {
236
+ const body = await res.text();
237
+ throw new Error(`Token refresh failed: ${res.status} ${body}. Run \`readwise-cli login\` again.`);
238
+ }
239
+
240
+ const tokens = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
241
+ config.access_token = tokens.access_token;
242
+ if (tokens.refresh_token) config.refresh_token = tokens.refresh_token;
243
+ config.expires_at = Date.now() + tokens.expires_in * 1000;
244
+ await saveConfig(config);
245
+ }
246
+
247
+ return { token: config.access_token, authType };
248
+ }
@@ -0,0 +1,158 @@
1
+ import { Command } from "commander";
2
+ import { ensureValidToken } from "./auth.js";
3
+ import { callTool } from "./mcp.js";
4
+ import type { ToolDef, SchemaProperty } from "./config.js";
5
+
6
+ export function toolNameToCommand(name: string): string {
7
+ return name.replace(/_/g, "-");
8
+ }
9
+
10
+ export function resolveRef(prop: SchemaProperty, defs?: Record<string, SchemaProperty>): SchemaProperty {
11
+ if (prop.$ref && defs) {
12
+ const name = prop.$ref.replace("#/$defs/", "");
13
+ const resolved = defs[name];
14
+ if (resolved) return { ...resolved, description: prop.description || resolved.description };
15
+ }
16
+ return prop;
17
+ }
18
+
19
+ export function resolveProperty(prop: SchemaProperty, defs?: Record<string, SchemaProperty>): SchemaProperty {
20
+ let p = resolveRef(prop, defs);
21
+ if (p.anyOf) {
22
+ const nonNull = p.anyOf.find((v) => v.type !== "null");
23
+ if (nonNull) {
24
+ p = { ...p, ...resolveRef(nonNull, defs), anyOf: undefined };
25
+ }
26
+ }
27
+ // Resolve items.$ref for array types
28
+ if (p.type === "array" && p.items) {
29
+ p = { ...p, items: resolveRef(p.items, defs) };
30
+ // Also resolve anyOf inside items (e.g. items wrapped in anyOf)
31
+ if (p.items?.anyOf) {
32
+ const nonNull = p.items.anyOf.find((v) => v.type !== "null");
33
+ if (nonNull) {
34
+ const resolvedItem = resolveRef(nonNull, defs);
35
+ p = { ...p, items: { ...p.items, ...resolvedItem, anyOf: undefined } };
36
+ }
37
+ }
38
+ }
39
+ return p;
40
+ }
41
+
42
+ function optionFlag(name: string, prop: SchemaProperty): string {
43
+ const flag = `--${name.replace(/_/g, "-")}`;
44
+
45
+ if (prop.type === "boolean") {
46
+ return flag;
47
+ }
48
+ return `${flag} <value>`;
49
+ }
50
+
51
+ function parseValue(value: string, prop: SchemaProperty): unknown {
52
+ if (prop.type === "integer" || prop.type === "number") {
53
+ const n = Number(value);
54
+ if (isNaN(n)) throw new Error(`Expected a number for value: ${value}`);
55
+ return n;
56
+ }
57
+ if (prop.type === "array") {
58
+ // Try JSON first, then comma-separated
59
+ try {
60
+ const parsed = JSON.parse(value);
61
+ if (Array.isArray(parsed)) return parsed;
62
+ } catch {
63
+ // fall through
64
+ }
65
+ return value.split(",").map((s) => s.trim());
66
+ }
67
+ if (prop.type === "boolean") {
68
+ return true;
69
+ }
70
+ return value;
71
+ }
72
+
73
+ export function displayResult(result: { content: Array<{ type: string; text?: string }>; structuredContent?: Record<string, unknown>; isError?: boolean }, json: boolean): void {
74
+ if (result.isError) {
75
+ for (const item of result.content) {
76
+ if (item.text) {
77
+ process.stderr.write(`\x1b[31mError: ${item.text}\x1b[0m\n`);
78
+ }
79
+ }
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+
84
+ // Prefer text content; fall back to structuredContent for empty results
85
+ let printed = false;
86
+ for (const item of result.content) {
87
+ if (item.type === "text" && item.text) {
88
+ printed = true;
89
+ if (json) {
90
+ process.stdout.write(item.text + "\n");
91
+ } else {
92
+ try {
93
+ const parsed = JSON.parse(item.text);
94
+ console.log(JSON.stringify(parsed, null, 2));
95
+ } catch {
96
+ console.log(item.text);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ if (!printed && result.structuredContent) {
103
+ const data = result.structuredContent;
104
+ if (json) {
105
+ process.stdout.write(JSON.stringify(data) + "\n");
106
+ } else {
107
+ console.log(JSON.stringify(data, null, 2));
108
+ }
109
+ }
110
+ }
111
+
112
+ export function registerTools(program: Command, tools: ToolDef[]): void {
113
+ for (const tool of tools) {
114
+ const cmd = program
115
+ .command(toolNameToCommand(tool.name))
116
+ .description(tool.description || "");
117
+
118
+ const properties = tool.inputSchema.properties || {};
119
+ const required = new Set(tool.inputSchema.required || []);
120
+ const defs = tool.inputSchema.$defs;
121
+
122
+ for (const [propName, rawProp] of Object.entries(properties)) {
123
+ const prop = resolveProperty(rawProp, defs);
124
+ const flag = optionFlag(propName, prop);
125
+ const parts: string[] = [];
126
+ if (prop.description) parts.push(prop.description);
127
+ if (required.has(propName)) parts.push("(required)");
128
+ const enumValues = prop.enum || prop.items?.enum;
129
+ if (enumValues) parts.push(`[${enumValues.join(", ")}]`);
130
+ if (prop.default !== undefined) parts.push(`(default: ${JSON.stringify(prop.default)})`);
131
+
132
+ cmd.option(flag, parts.join(" ") || undefined);
133
+ }
134
+
135
+ cmd.action(async (options: Record<string, string>) => {
136
+ try {
137
+ const { token, authType } = await ensureValidToken();
138
+
139
+ // Convert commander options back to tool arguments
140
+ const args: Record<string, unknown> = {};
141
+ for (const [propName, rawProp] of Object.entries(properties)) {
142
+ const prop = resolveProperty(rawProp, defs);
143
+ const camelKey = propName.replace(/_/g, "-").replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
144
+ const value = options[camelKey];
145
+ if (value !== undefined) {
146
+ args[propName] = parseValue(String(value), prop);
147
+ }
148
+ }
149
+
150
+ const result = await callTool(token, authType, tool.name, args);
151
+ displayResult(result, program.opts().json || false);
152
+ } catch (err) {
153
+ process.stderr.write(`\x1b[31m${(err as Error).message}\x1b[0m\n`);
154
+ process.exitCode = 1;
155
+ }
156
+ });
157
+ }
158
+ }