@mitsein-ai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ import type { Command } from 'commander';
2
+ import { ApiClient } from '../core/client.js';
3
+ import { resolveCredentials } from '../core/credentials.js';
4
+ import { CliError, ExitCode, handleErrors } from '../core/errors.js';
5
+ import { emit, setJsonMode } from '../core/output.js';
6
+ import { waitForRun } from '../core/waiters.js';
7
+ import { commaSet, httpTimeoutSec, readGlobals } from './command-opts.js';
8
+
9
+ export function registerThread(program: Command): void {
10
+ const thread = program.command('thread').description('Thread management and messaging');
11
+
12
+ thread
13
+ .command('list')
14
+ .description('List threads')
15
+ .option('--offset <n>', 'Pagination offset', '0')
16
+ .option('--limit <n>', 'Max results', '20')
17
+ .action(
18
+ handleErrors(async function threadListAction(this: Command) {
19
+ const g = readGlobals(this);
20
+ setJsonMode(Boolean(g.json));
21
+ const client = ApiClient.fromOptions({
22
+ token: g.token,
23
+ endpoint: g.endpoint,
24
+ profile: g.profile ?? 'e2e',
25
+ real: g.real,
26
+ timeoutSec: httpTimeoutSec(g),
27
+ debug: g.debug,
28
+ });
29
+ const offset = Number.parseInt(String(this.opts().offset ?? '0'), 10);
30
+ const limit = Number.parseInt(String(this.opts().limit ?? '20'), 10);
31
+ const result = await client.post('/api/thread/list', {
32
+ offset: Number.isFinite(offset) ? offset : 0,
33
+ limit: Number.isFinite(limit) ? limit : 20,
34
+ });
35
+ emit(result);
36
+ })
37
+ );
38
+
39
+ thread
40
+ .command('get <thread_id>')
41
+ .description('Get thread details')
42
+ .action(
43
+ handleErrors(async function threadGetAction(this: Command, threadId: string) {
44
+ const g = readGlobals(this);
45
+ setJsonMode(Boolean(g.json));
46
+ const client = ApiClient.fromOptions({
47
+ token: g.token,
48
+ endpoint: g.endpoint,
49
+ profile: g.profile ?? 'e2e',
50
+ real: g.real,
51
+ timeoutSec: httpTimeoutSec(g),
52
+ debug: g.debug,
53
+ });
54
+ const result = await client.post('/api/thread/get', { thread_id: threadId });
55
+ emit(result);
56
+ })
57
+ );
58
+
59
+ thread
60
+ .command('create')
61
+ .description('Create a new thread')
62
+ .option('--title <title>', 'Thread title', '')
63
+ .action(
64
+ handleErrors(async function threadCreateAction(this: Command) {
65
+ const g = readGlobals(this);
66
+ const { title } = this.opts() as { title?: string };
67
+ setJsonMode(Boolean(g.json));
68
+ const client = ApiClient.fromOptions({
69
+ token: g.token,
70
+ endpoint: g.endpoint,
71
+ profile: g.profile ?? 'e2e',
72
+ real: g.real,
73
+ timeoutSec: httpTimeoutSec(g),
74
+ debug: g.debug,
75
+ });
76
+ const result = await client.post('/api/thread/create', { title: title ?? '' });
77
+ emit(result);
78
+ })
79
+ );
80
+
81
+ thread
82
+ .command('history <thread_id>')
83
+ .description('Show message history for a thread')
84
+ .option('--limit <n>', 'Max messages', '50')
85
+ .action(
86
+ handleErrors(async function threadHistoryAction(this: Command, threadId: string) {
87
+ const g = readGlobals(this);
88
+ const { limit: lim } = this.opts() as { limit?: string };
89
+ setJsonMode(Boolean(g.json));
90
+ const client = ApiClient.fromOptions({
91
+ token: g.token,
92
+ endpoint: g.endpoint,
93
+ profile: g.profile ?? 'e2e',
94
+ real: g.real,
95
+ timeoutSec: httpTimeoutSec(g),
96
+ debug: g.debug,
97
+ });
98
+ const limit = Number.parseInt(String(lim ?? '50'), 10);
99
+ const result = await client.post('/api/message/list', {
100
+ thread_id: threadId,
101
+ limit: Number.isFinite(limit) ? limit : 50,
102
+ });
103
+ emit(result);
104
+ })
105
+ );
106
+
107
+ thread
108
+ .command('send <thread_id> <message>')
109
+ .description('Send a message and start an agent run')
110
+ .option('--wait', 'Block until run completes', false)
111
+ .option('--stream', 'Stream run events', false)
112
+ .option('--cancel-on-interrupt', 'Cancel run on Ctrl-C (reserved)', false)
113
+ .option('--filter <types>', 'Comma-separated SSE event types')
114
+ .option('--agent <name>', 'Agent name (reserved)', '')
115
+ .option('--model <name>', 'Model override')
116
+ .option('--timeout <sec>', 'Wait/stream timeout (0 = no limit)', '120')
117
+ .action(
118
+ handleErrors(async function threadSendAction(this: Command, threadId: string, message: string) {
119
+ const g = readGlobals(this);
120
+ const opts = this.opts() as {
121
+ wait?: boolean;
122
+ stream?: boolean;
123
+ filter?: string;
124
+ model?: string;
125
+ timeout?: string;
126
+ };
127
+ setJsonMode(Boolean(g.json));
128
+
129
+ if (opts.wait && opts.stream) {
130
+ throw new CliError('--wait and --stream are mutually exclusive', ExitCode.USAGE_ERROR);
131
+ }
132
+
133
+ const creds = resolveCredentials({
134
+ token: g.token,
135
+ endpoint: g.endpoint,
136
+ profile: g.profile ?? 'e2e',
137
+ real: g.real,
138
+ });
139
+ const client = new ApiClient(creds, {
140
+ debug: g.debug,
141
+ timeoutSec: httpTimeoutSec(g),
142
+ });
143
+
144
+ const addResult = await client.post('/api/message/add', {
145
+ thread_id: threadId,
146
+ content: message,
147
+ });
148
+
149
+ const startData: Record<string, string> = {};
150
+ if (opts.model) {
151
+ startData.model_name = opts.model;
152
+ }
153
+ const startResult = (await client.postForm(`/api/thread/${threadId}/agent/start`, startData)) as {
154
+ agent_run_id?: string;
155
+ };
156
+ const agentRunId = startResult.agent_run_id ?? '';
157
+
158
+ if (!opts.wait && !opts.stream) {
159
+ emit({
160
+ message: addResult,
161
+ agent_run_id: agentRunId,
162
+ status: 'started',
163
+ });
164
+ return;
165
+ }
166
+
167
+ const rawT = opts.timeout ?? '120';
168
+ const tNum = Number.parseFloat(rawT);
169
+ const effectiveTimeout = Number.isFinite(tNum) && tNum === 0 ? null : Number.isFinite(tNum) ? tNum : 120;
170
+
171
+ const result = await waitForRun({
172
+ endpoint: creds.endpoint,
173
+ token: creds.token,
174
+ agent_run_id: agentRunId,
175
+ timeout: effectiveTimeout ?? undefined,
176
+ stream_output: Boolean(opts.stream),
177
+ json_mode: Boolean(g.json),
178
+ debug: g.debug,
179
+ event_filter: commaSet(opts.filter),
180
+ });
181
+
182
+ if (opts.wait && !opts.stream) {
183
+ emit(result);
184
+ }
185
+ })
186
+ );
187
+ }
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { emit } from '../core/output.js';
4
+
5
+ export function getPackageVersion(): string {
6
+ const pkgPath = join(import.meta.dir, '..', '..', 'package.json');
7
+ try {
8
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
9
+ return pkg.version ?? '0.1.0-dev';
10
+ } catch {
11
+ return '0.1.0-dev';
12
+ }
13
+ }
14
+
15
+ export function runVersion(): void {
16
+ emit(getPackageVersion());
17
+ }
@@ -0,0 +1,201 @@
1
+ import { type $Fetch, FetchError, ofetch } from 'ofetch';
2
+ import type { Credentials } from './credentials.js';
3
+ import { resolveCredentials, type ResolveCredentialsOptions } from './credentials.js';
4
+ import { HttpError } from './errors.js';
5
+
6
+ export interface ApiClientOptions {
7
+ timeoutSec?: number | undefined;
8
+ debug?: boolean | undefined;
9
+ /** Injected `$fetch` instance (tests). */
10
+ fetcher?: $Fetch | undefined;
11
+ }
12
+
13
+ function normalizeBase(endpoint: string): string {
14
+ return endpoint.replace(/\/$/, '');
15
+ }
16
+
17
+ function logDebug(debug: boolean, message: string): void {
18
+ if (debug) {
19
+ process.stderr.write(`[debug] ${message}\n`);
20
+ }
21
+ }
22
+
23
+ function parseErrorBody(body: unknown): { message: string; detail: unknown } {
24
+ let message = 'HTTP error';
25
+ let detail: unknown;
26
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
27
+ const o = body as Record<string, unknown>;
28
+ message = typeof o.message === 'string' ? o.message : message;
29
+ detail = o.detail ?? o.error;
30
+ }
31
+ return { message, detail };
32
+ }
33
+
34
+ async function handleFetch<T>(p: Promise<T>): Promise<T> {
35
+ try {
36
+ return await p;
37
+ } catch (e) {
38
+ if (e instanceof FetchError) {
39
+ const status = e.statusCode ?? e.status ?? 0;
40
+ const { message, detail } = parseErrorBody(e.data);
41
+ throw new HttpError(status || 599, message, detail);
42
+ }
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ export class ApiClient {
48
+ private readonly timeoutMs: number | undefined;
49
+ private readonly debug: boolean;
50
+ private readonly fetchImpl: $Fetch;
51
+ private readonly baseUrl: string;
52
+ private readonly bearerToken: string;
53
+
54
+ constructor(credentials: Credentials, options: ApiClientOptions = {}) {
55
+ this.debug = options.debug ?? false;
56
+ this.timeoutMs =
57
+ options.timeoutSec === undefined || options.timeoutSec === 0
58
+ ? undefined
59
+ : options.timeoutSec * 1000;
60
+ this.baseUrl = normalizeBase(credentials.endpoint);
61
+ this.bearerToken = credentials.token;
62
+
63
+ const dbg = this.debug;
64
+ this.fetchImpl =
65
+ options.fetcher ??
66
+ ofetch.create({
67
+ baseURL: this.baseUrl,
68
+ timeout: this.timeoutMs,
69
+ headers: {
70
+ Authorization: `Bearer ${credentials.token}`,
71
+ 'Content-Type': 'application/json',
72
+ },
73
+ retry: 0,
74
+ onRequest({ request }) {
75
+ logDebug(dbg, `${request.method} ${request.url}`);
76
+ },
77
+ onResponse({ response }) {
78
+ logDebug(
79
+ dbg,
80
+ `→ ${response.status} (${response.headers.get('content-length') ?? '?'} bytes)`
81
+ );
82
+ },
83
+ });
84
+ }
85
+
86
+ static fromOptions(opts: ResolveCredentialsOptions & ApiClientOptions): ApiClient {
87
+ const { timeoutSec, debug, fetcher, ...credOpts } = opts;
88
+ const credentials = resolveCredentials(credOpts);
89
+ return new ApiClient(credentials, { timeoutSec, debug, fetcher });
90
+ }
91
+
92
+ async get(path: string, query?: Record<string, unknown> | null): Promise<unknown> {
93
+ const p = path.startsWith('/') ? path : `/${path}`;
94
+ return handleFetch(this.fetchImpl(p, { method: 'GET', query: query ?? undefined }));
95
+ }
96
+
97
+ async post(path: string, jsonBody?: unknown | null): Promise<unknown> {
98
+ const p = path.startsWith('/') ? path : `/${path}`;
99
+ if (this.debug && jsonBody !== undefined && jsonBody !== null) {
100
+ logDebug(this.debug, `body: ${JSON.stringify(jsonBody)}`);
101
+ }
102
+ return handleFetch(
103
+ this.fetchImpl(p, {
104
+ method: 'POST',
105
+ body: jsonBody === undefined || jsonBody === null ? undefined : (jsonBody as object),
106
+ })
107
+ );
108
+ }
109
+
110
+ async postForm(path: string, data?: Record<string, unknown> | null): Promise<unknown> {
111
+ const p = path.startsWith('/') ? path : `/${path}`;
112
+ const payload: Record<string, string> = {};
113
+ for (const [k, v] of Object.entries(data ?? {})) {
114
+ if (v !== undefined && v !== null) {
115
+ payload[k] = String(v);
116
+ }
117
+ }
118
+ if (this.debug) {
119
+ logDebug(this.debug, `POST ${p}`);
120
+ logDebug(this.debug, `form: ${JSON.stringify(payload)}`);
121
+ }
122
+ const url = `${this.baseUrl}${p}`;
123
+ const body = new URLSearchParams(payload);
124
+ const controller = new AbortController();
125
+ const ms = this.timeoutMs;
126
+ const timer =
127
+ ms === undefined ? null : setTimeout(() => controller.abort(), ms);
128
+ try {
129
+ const r = await fetch(url, {
130
+ method: 'POST',
131
+ headers: {
132
+ Authorization: `Bearer ${this.bearerToken}`,
133
+ 'Content-Type': 'application/x-www-form-urlencoded',
134
+ },
135
+ body,
136
+ signal: controller.signal,
137
+ });
138
+ logDebug(this.debug, `→ ${r.status} (${r.headers.get('content-length') ?? '?'} bytes)`);
139
+ if (r.ok) {
140
+ const ct = r.headers.get('content-type') ?? '';
141
+ if (ct.includes('application/json')) {
142
+ return r.json();
143
+ }
144
+ return r.text();
145
+ }
146
+ let message = `HTTP ${r.status}`;
147
+ let detail: unknown;
148
+ try {
149
+ const j = (await r.json()) as Record<string, unknown>;
150
+ message = typeof j.message === 'string' ? j.message : message;
151
+ detail = j.detail ?? j.error;
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ throw new HttpError(r.status, message, detail);
156
+ } catch (e) {
157
+ if (e instanceof HttpError) {
158
+ throw e;
159
+ }
160
+ if (e instanceof Error && e.name === 'AbortError') {
161
+ throw new HttpError(599, 'Request aborted or timed out');
162
+ }
163
+ throw e;
164
+ } finally {
165
+ if (timer) {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+ }
170
+
171
+ async patch(path: string, jsonBody?: unknown | null): Promise<unknown> {
172
+ const p = path.startsWith('/') ? path : `/${path}`;
173
+ if (this.debug && jsonBody !== undefined && jsonBody !== null) {
174
+ logDebug(this.debug, `body: ${JSON.stringify(jsonBody)}`);
175
+ }
176
+ return handleFetch(
177
+ this.fetchImpl(p, {
178
+ method: 'PATCH',
179
+ body: jsonBody === undefined || jsonBody === null ? undefined : (jsonBody as object),
180
+ })
181
+ );
182
+ }
183
+
184
+ async put(path: string, jsonBody?: unknown | null): Promise<unknown> {
185
+ const p = path.startsWith('/') ? path : `/${path}`;
186
+ if (this.debug && jsonBody !== undefined && jsonBody !== null) {
187
+ logDebug(this.debug, `body: ${JSON.stringify(jsonBody)}`);
188
+ }
189
+ return handleFetch(
190
+ this.fetchImpl(p, {
191
+ method: 'PUT',
192
+ body: jsonBody === undefined || jsonBody === null ? undefined : (jsonBody as object),
193
+ })
194
+ );
195
+ }
196
+
197
+ async delete(path: string): Promise<unknown> {
198
+ const p = path.startsWith('/') ? path : `/${path}`;
199
+ return handleFetch(this.fetchImpl(p, { method: 'DELETE' }));
200
+ }
201
+ }
@@ -0,0 +1,26 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ /** Default API base URL (matches Python `DEFAULT_ENDPOINT`). */
6
+ export const DEFAULT_ENDPOINT = 'http://localhost:8000';
7
+
8
+ /** Default OAuth / profile name. */
9
+ export const DEFAULT_PROFILE = 'e2e';
10
+
11
+ /** Base config directory: `~/.mitsein`. */
12
+ export const CONFIG_DIR = join(homedir(), '.mitsein');
13
+
14
+ /** Create `~/.mitsein/` if missing (`0o700`). */
15
+ export function ensureConfigDir(): string {
16
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
17
+ return CONFIG_DIR;
18
+ }
19
+
20
+ export function getOauthPath(profile: string = DEFAULT_PROFILE): string {
21
+ return join(CONFIG_DIR, 'oauth', `${profile}.json`);
22
+ }
23
+
24
+ export function getOpenapiCachePath(): string {
25
+ return join(CONFIG_DIR, 'openapi.json');
26
+ }
@@ -0,0 +1,173 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { DEFAULT_ENDPOINT, getOauthPath } from './config.js';
4
+ import { NoCredentialsError } from './errors.js';
5
+
6
+ export interface Credentials {
7
+ token: string;
8
+ endpoint: string;
9
+ }
10
+
11
+ export interface ResolveCredentialsOptions {
12
+ token?: string | undefined;
13
+ endpoint?: string | undefined;
14
+ profile?: string | undefined;
15
+ real?: boolean | undefined;
16
+ projectRoot?: string | undefined;
17
+ /** Injected for tests (Bun.spawnSync). */
18
+ spawnDevToken?: typeof runDevTokenScript;
19
+ }
20
+
21
+ function parseUrlHost(endpoint: string): string {
22
+ try {
23
+ return new URL(endpoint).hostname;
24
+ } catch {
25
+ return '';
26
+ }
27
+ }
28
+
29
+ function isLocalhost(endpoint: string): boolean {
30
+ const host = parseUrlHost(endpoint);
31
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0';
32
+ }
33
+
34
+ function findDevTokenScript(projectRoot: string | undefined): string | null {
35
+ const roots: string[] = [];
36
+ if (projectRoot) {
37
+ roots.push(projectRoot);
38
+ }
39
+ roots.push(process.cwd());
40
+
41
+ for (const root of roots) {
42
+ let current = root;
43
+ for (let depth = 0; depth < 10; depth++) {
44
+ const candidate = join(current, 'scripts', 'dev-token.sh');
45
+ if (existsSync(candidate)) {
46
+ return candidate;
47
+ }
48
+ const parent = join(current, '..');
49
+ if (parent === current) {
50
+ break;
51
+ }
52
+ current = parent;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function runDevTokenScript(scriptPath: string, real: boolean): { ok: boolean; stdout: string } {
59
+ const args = [scriptPath, '--raw'];
60
+ if (real) {
61
+ args.push('--real');
62
+ }
63
+ const proc = Bun.spawnSync(['bash', ...args], {
64
+ timeout: 10_000,
65
+ stdout: 'pipe',
66
+ stderr: 'pipe',
67
+ });
68
+ const stdout = proc.stdout.toString().trim();
69
+ return { ok: proc.success && stdout.length > 0, stdout };
70
+ }
71
+
72
+ function loadExplicit(token: string | undefined, endpoint: string | undefined): Credentials | null {
73
+ if (token) {
74
+ return { token, endpoint: endpoint ?? DEFAULT_ENDPOINT };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function loadEnv(): Credentials | null {
80
+ const token = process.env.MITSEIN_TOKEN;
81
+ if (token) {
82
+ return {
83
+ token,
84
+ endpoint: process.env.MITSEIN_API_URL ?? DEFAULT_ENDPOINT,
85
+ };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function loadOAuth(profile: string, endpointOverride: string | undefined): Credentials | null {
91
+ const oauthPath = getOauthPath(profile);
92
+ if (!existsSync(oauthPath)) {
93
+ return null;
94
+ }
95
+ try {
96
+ const raw = readFileSync(oauthPath, 'utf8');
97
+ const data = JSON.parse(raw) as { access_token?: string; endpoint?: string };
98
+ const token = data.access_token;
99
+ if (token) {
100
+ return {
101
+ token,
102
+ endpoint: endpointOverride ?? data.endpoint ?? DEFAULT_ENDPOINT,
103
+ };
104
+ }
105
+ } catch {
106
+ /* ignore malformed oauth file */
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function loadDevToken(
112
+ endpoint: string | undefined,
113
+ real: boolean,
114
+ projectRoot: string | undefined,
115
+ spawnDevToken: typeof runDevTokenScript
116
+ ): Credentials | null {
117
+ const resolvedEndpoint = endpoint ?? DEFAULT_ENDPOINT;
118
+ if (!isLocalhost(resolvedEndpoint)) {
119
+ return null;
120
+ }
121
+ const script = findDevTokenScript(projectRoot);
122
+ if (!script) {
123
+ return null;
124
+ }
125
+ try {
126
+ const { ok, stdout } = spawnDevToken(script, real);
127
+ if (ok) {
128
+ return { token: stdout, endpoint: resolvedEndpoint };
129
+ }
130
+ } catch {
131
+ /* timeout / missing bash */
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Credential provider chain (Python `resolve_credentials`):
138
+ * 1. Explicit --token / --endpoint
139
+ * 2. MITSEIN_TOKEN / MITSEIN_API_URL
140
+ * 3. ~/.mitsein/oauth/<profile>.json
141
+ * 4. scripts/dev-token.sh (localhost only)
142
+ */
143
+ export function resolveCredentials(options: ResolveCredentialsOptions = {}): Credentials {
144
+ const profile = options.profile ?? 'e2e';
145
+ const spawnFn = options.spawnDevToken ?? runDevTokenScript;
146
+
147
+ const explicit = loadExplicit(options.token, options.endpoint);
148
+ if (explicit) {
149
+ return explicit;
150
+ }
151
+
152
+ const fromEnv = loadEnv();
153
+ if (fromEnv) {
154
+ return fromEnv;
155
+ }
156
+
157
+ const fromOAuth = loadOAuth(profile, options.endpoint);
158
+ if (fromOAuth) {
159
+ return fromOAuth;
160
+ }
161
+
162
+ const fromDev = loadDevToken(
163
+ options.endpoint,
164
+ options.real ?? false,
165
+ options.projectRoot,
166
+ spawnFn
167
+ );
168
+ if (fromDev) {
169
+ return fromDev;
170
+ }
171
+
172
+ throw new NoCredentialsError();
173
+ }
@@ -0,0 +1,76 @@
1
+ import type { Command } from 'commander';
2
+ import consola from 'consola';
3
+
4
+ /** Standard CLI exit codes (Python `ExitCode`). */
5
+ export enum ExitCode {
6
+ SUCCESS = 0,
7
+ BUSINESS_ERROR = 1,
8
+ USAGE_ERROR = 2,
9
+ HTTP_ERROR = 3,
10
+ TIMEOUT = 124,
11
+ }
12
+
13
+ export type ErrorDetail = unknown;
14
+
15
+ /** Structured CLI error with exit code. */
16
+ export class CliError extends Error {
17
+ readonly code: ExitCode;
18
+ readonly detail: ErrorDetail;
19
+
20
+ constructor(message: string, code: ExitCode = ExitCode.BUSINESS_ERROR, detail?: ErrorDetail) {
21
+ super(message);
22
+ this.name = 'CliError';
23
+ this.code = code;
24
+ this.detail = detail;
25
+ }
26
+ }
27
+
28
+ /** No credentials matched the provider chain. */
29
+ export class NoCredentialsError extends CliError {
30
+ constructor(
31
+ message = 'No credentials found. Set MITSEIN_TOKEN, pass --token, or run from a dev environment with scripts/dev-token.sh.'
32
+ ) {
33
+ super(message, ExitCode.USAGE_ERROR);
34
+ this.name = 'NoCredentialsError';
35
+ }
36
+ }
37
+
38
+ /** HTTP request failed with a non-success status. */
39
+ export class HttpError extends CliError {
40
+ readonly statusCode: number;
41
+
42
+ constructor(statusCode: number, message: string, detail?: ErrorDetail) {
43
+ const code =
44
+ statusCode >= 400 && statusCode < 500 ? ExitCode.BUSINESS_ERROR : ExitCode.HTTP_ERROR;
45
+ super(message, code, detail);
46
+ this.name = 'HttpError';
47
+ this.statusCode = statusCode;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Wrap a Commander action: catch `CliError`, print, `process.exit(code)`;
53
+ * `KeyboardInterrupt` equivalent → exit 130.
54
+ */
55
+ export function handleErrors<T extends unknown[]>(
56
+ fn: (this: Command, ...args: T) => void | Promise<void>
57
+ ): (this: Command, ...args: T) => Promise<void> {
58
+ return async function handleErrorsWrapped(this: Command, ...args: T): Promise<void> {
59
+ try {
60
+ await Promise.resolve(fn.apply(this, args));
61
+ } catch (e) {
62
+ if (e instanceof CliError) {
63
+ consola.error(`Error: ${e.message}`);
64
+ if (e.detail != null && e.detail !== '') {
65
+ consola.error(String(e.detail));
66
+ }
67
+ process.exit(e.code);
68
+ }
69
+ if (e !== null && typeof e === 'object' && (e as Error).name === 'AbortError') {
70
+ process.stderr.write('\nInterrupted.\n');
71
+ process.exit(130);
72
+ }
73
+ throw e;
74
+ }
75
+ };
76
+ }