@getrouter/getrouter-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.
- package/.github/workflows/ci.yml +19 -0
- package/AGENTS.md +78 -0
- package/README.ja.md +116 -0
- package/README.md +116 -0
- package/README.zh-cn.md +116 -0
- package/biome.json +10 -0
- package/bun.lock +397 -0
- package/dist/bin.mjs +1422 -0
- package/docs/plans/2026-01-01-getrouter-cli-config-command-plan.md +231 -0
- package/docs/plans/2026-01-01-getrouter-cli-config-core-plan.md +307 -0
- package/docs/plans/2026-01-01-getrouter-cli-design.md +106 -0
- package/docs/plans/2026-01-01-getrouter-cli-scaffold-plan.md +327 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-design.md +68 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-device-design.md +73 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-device-plan.md +411 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-plan.md +435 -0
- package/docs/plans/2026-01-02-getrouter-cli-http-client-plan.md +235 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-design.md +24 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-plan.md +141 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-design.md +22 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-plan.md +122 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-design.md +23 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-plan.md +141 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-design.md +28 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-plan.md +247 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-output-design.md +31 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-output-plan.md +187 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-design.md +52 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-plan.md +306 -0
- package/docs/plans/2026-01-02-getrouter-cli-setup-env-design.md +67 -0
- package/docs/plans/2026-01-02-getrouter-cli-setup-env-plan.md +441 -0
- package/docs/plans/2026-01-02-getrouter-cli-subscription-output-design.md +34 -0
- package/docs/plans/2026-01-02-getrouter-cli-subscription-output-plan.md +157 -0
- package/docs/plans/2026-01-03-bun-migration-plan.md +103 -0
- package/docs/plans/2026-01-03-cli-emoji-output.md +45 -0
- package/docs/plans/2026-01-03-cli-english-output.md +123 -0
- package/docs/plans/2026-01-03-cli-simplify-design.md +62 -0
- package/docs/plans/2026-01-03-cli-simplify-implementation.md +468 -0
- package/docs/plans/2026-01-03-readme-command-descriptions.md +116 -0
- package/docs/plans/2026-01-03-tsdown-migration-plan.md +75 -0
- package/docs/plans/2026-01-04-cli-docs-cleanup-design.md +49 -0
- package/docs/plans/2026-01-04-cli-docs-cleanup-plan.md +126 -0
- package/docs/plans/2026-01-04-codex-multistep-design.md +76 -0
- package/docs/plans/2026-01-04-codex-multistep-plan.md +240 -0
- package/docs/plans/2026-01-04-env-hook-design.md +48 -0
- package/docs/plans/2026-01-04-env-hook-plan.md +173 -0
- package/docs/plans/2026-01-04-models-keys-fuzzy-design.md +75 -0
- package/docs/plans/2026-01-04-models-keys-fuzzy-implementation.md +704 -0
- package/package.json +37 -0
- package/src/.gitkeep +0 -0
- package/src/bin.ts +4 -0
- package/src/cli.ts +12 -0
- package/src/cmd/auth.ts +44 -0
- package/src/cmd/claude.ts +10 -0
- package/src/cmd/codex.ts +119 -0
- package/src/cmd/config-helpers.ts +16 -0
- package/src/cmd/config.ts +31 -0
- package/src/cmd/env.ts +103 -0
- package/src/cmd/index.ts +20 -0
- package/src/cmd/keys.ts +207 -0
- package/src/cmd/models.ts +48 -0
- package/src/cmd/status.ts +106 -0
- package/src/cmd/usages.ts +29 -0
- package/src/core/api/client.ts +79 -0
- package/src/core/auth/device.ts +105 -0
- package/src/core/auth/index.ts +37 -0
- package/src/core/config/fs.ts +13 -0
- package/src/core/config/index.ts +37 -0
- package/src/core/config/paths.ts +5 -0
- package/src/core/config/redact.ts +18 -0
- package/src/core/config/types.ts +23 -0
- package/src/core/http/errors.ts +32 -0
- package/src/core/http/request.ts +41 -0
- package/src/core/http/url.ts +12 -0
- package/src/core/interactive/clipboard.ts +61 -0
- package/src/core/interactive/codex.ts +75 -0
- package/src/core/interactive/fuzzy.ts +64 -0
- package/src/core/interactive/keys.ts +164 -0
- package/src/core/output/table.ts +34 -0
- package/src/core/output/usages.ts +75 -0
- package/src/core/paths.ts +4 -0
- package/src/core/setup/codex.ts +129 -0
- package/src/core/setup/env.ts +220 -0
- package/src/core/usages/aggregate.ts +69 -0
- package/src/generated/router/dashboard/v1/index.ts +1104 -0
- package/src/index.ts +1 -0
- package/tests/.gitkeep +0 -0
- package/tests/auth/device.test.ts +75 -0
- package/tests/auth/status.test.ts +64 -0
- package/tests/cli.test.ts +31 -0
- package/tests/cmd/auth.test.ts +90 -0
- package/tests/cmd/claude.test.ts +132 -0
- package/tests/cmd/codex.test.ts +147 -0
- package/tests/cmd/config-helpers.test.ts +18 -0
- package/tests/cmd/config.test.ts +56 -0
- package/tests/cmd/keys.test.ts +163 -0
- package/tests/cmd/models.test.ts +63 -0
- package/tests/cmd/status.test.ts +82 -0
- package/tests/cmd/usages.test.ts +42 -0
- package/tests/config/fs.test.ts +14 -0
- package/tests/config/index.test.ts +63 -0
- package/tests/config/paths.test.ts +10 -0
- package/tests/config/redact.test.ts +17 -0
- package/tests/config/types.test.ts +10 -0
- package/tests/core/api/client.test.ts +92 -0
- package/tests/core/interactive/clipboard.test.ts +44 -0
- package/tests/core/interactive/codex.test.ts +17 -0
- package/tests/core/interactive/fuzzy.test.ts +30 -0
- package/tests/core/setup/codex.test.ts +38 -0
- package/tests/core/setup/env.test.ts +84 -0
- package/tests/core/usages/aggregate.test.ts +55 -0
- package/tests/http/errors.test.ts +15 -0
- package/tests/http/request.test.ts +82 -0
- package/tests/http/url.test.ts +17 -0
- package/tests/output/table.test.ts +29 -0
- package/tests/output/usages.test.ts +71 -0
- package/tests/paths.test.ts +9 -0
- package/tsconfig.json +13 -0
- package/tsdown.config.ts +5 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { createApiClients } from "../core/api/client";
|
|
3
|
+
import { getAuthStatus } from "../core/auth";
|
|
4
|
+
|
|
5
|
+
const LABEL_WIDTH = 10;
|
|
6
|
+
|
|
7
|
+
const formatLine = (label: string, value: string | number | undefined) => {
|
|
8
|
+
if (value == null || value === "") return null;
|
|
9
|
+
return ` ${label.padEnd(LABEL_WIDTH, " ")}: ${value}`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const formatAuthStatus = (status: "logged_in" | "logged_out") =>
|
|
13
|
+
status === "logged_in" ? "✅ Logged in" : "❌ Logged out";
|
|
14
|
+
|
|
15
|
+
const formatToken = (token?: string) => {
|
|
16
|
+
if (!token) return undefined;
|
|
17
|
+
const trimmed = token.trim();
|
|
18
|
+
if (trimmed.length <= 12) return trimmed;
|
|
19
|
+
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const formatWindow = (startAt?: string, endAt?: string) => {
|
|
23
|
+
if (!startAt && !endAt) return undefined;
|
|
24
|
+
if (startAt && endAt) return `${startAt} → ${endAt}`;
|
|
25
|
+
if (startAt) return `${startAt} →`;
|
|
26
|
+
return `→ ${endAt}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const formatLimits = (
|
|
30
|
+
requestPerMinute?: number,
|
|
31
|
+
tokenPerMinute?: string | number,
|
|
32
|
+
) => {
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
if (typeof requestPerMinute === "number") {
|
|
35
|
+
parts.push(`${requestPerMinute} req/min`);
|
|
36
|
+
}
|
|
37
|
+
if (tokenPerMinute) {
|
|
38
|
+
parts.push(`${tokenPerMinute} tok/min`);
|
|
39
|
+
}
|
|
40
|
+
if (parts.length === 0) return undefined;
|
|
41
|
+
return parts.join(" · ");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const renderAuthSection = () => {
|
|
45
|
+
const status = getAuthStatus();
|
|
46
|
+
const lines = [
|
|
47
|
+
formatLine("Status", formatAuthStatus(status.status)),
|
|
48
|
+
status.status === "logged_in"
|
|
49
|
+
? formatLine("Expires", status.expiresAt)
|
|
50
|
+
: null,
|
|
51
|
+
status.status === "logged_in"
|
|
52
|
+
? formatLine("TokenType", status.tokenType)
|
|
53
|
+
: null,
|
|
54
|
+
status.status === "logged_in"
|
|
55
|
+
? formatLine("Access", formatToken(status.accessToken))
|
|
56
|
+
: null,
|
|
57
|
+
status.status === "logged_in"
|
|
58
|
+
? formatLine("Refresh", formatToken(status.refreshToken))
|
|
59
|
+
: null,
|
|
60
|
+
].filter(Boolean) as string[];
|
|
61
|
+
return ["🔐 Auth", ...lines].join("\n");
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const renderSubscriptionSection = (
|
|
65
|
+
subscription: {
|
|
66
|
+
plan?: {
|
|
67
|
+
name?: string;
|
|
68
|
+
requestPerMinute?: number;
|
|
69
|
+
tokenPerMinute?: string | number;
|
|
70
|
+
};
|
|
71
|
+
status?: string;
|
|
72
|
+
startAt?: string;
|
|
73
|
+
endAt?: string;
|
|
74
|
+
} | null,
|
|
75
|
+
) => {
|
|
76
|
+
if (!subscription) {
|
|
77
|
+
return ["📦 Subscription", formatLine("Status", "No active subscription")]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join("\n");
|
|
80
|
+
}
|
|
81
|
+
const limits = formatLimits(
|
|
82
|
+
subscription.plan?.requestPerMinute,
|
|
83
|
+
subscription.plan?.tokenPerMinute,
|
|
84
|
+
);
|
|
85
|
+
const windowLabel = formatWindow(subscription.startAt, subscription.endAt);
|
|
86
|
+
const lines = [
|
|
87
|
+
formatLine("Plan", subscription.plan?.name),
|
|
88
|
+
formatLine("Status", subscription.status),
|
|
89
|
+
formatLine("Window", windowLabel),
|
|
90
|
+
formatLine("Limits", limits),
|
|
91
|
+
].filter(Boolean) as string[];
|
|
92
|
+
return ["📦 Subscription", ...lines].join("\n");
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const registerStatusCommand = (program: Command) => {
|
|
96
|
+
program
|
|
97
|
+
.command("status")
|
|
98
|
+
.description("Show login and subscription status")
|
|
99
|
+
.action(async () => {
|
|
100
|
+
const { subscriptionService } = createApiClients({});
|
|
101
|
+
const subscription = await subscriptionService.CurrentSubscription({});
|
|
102
|
+
console.log(renderAuthSection());
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log(renderSubscriptionSection(subscription));
|
|
105
|
+
});
|
|
106
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { createApiClients } from "../core/api/client";
|
|
3
|
+
import { renderUsageChart } from "../core/output/usages";
|
|
4
|
+
import { aggregateUsages, type RawUsage } from "../core/usages/aggregate";
|
|
5
|
+
|
|
6
|
+
type UsageListResponse = {
|
|
7
|
+
usages?: RawUsage[];
|
|
8
|
+
nextPageToken?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const collectUsages = async () => {
|
|
12
|
+
const { usageService } = createApiClients({});
|
|
13
|
+
const res = (await usageService.ListUsage({
|
|
14
|
+
pageSize: 7,
|
|
15
|
+
pageToken: undefined,
|
|
16
|
+
})) as UsageListResponse;
|
|
17
|
+
const usages = res?.usages ?? [];
|
|
18
|
+
return aggregateUsages(usages, 7);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const registerUsagesCommand = (program: Command) => {
|
|
22
|
+
program
|
|
23
|
+
.command("usages")
|
|
24
|
+
.description("Show recent usage")
|
|
25
|
+
.action(async () => {
|
|
26
|
+
const aggregated = await collectUsages();
|
|
27
|
+
console.log(renderUsageChart(aggregated));
|
|
28
|
+
});
|
|
29
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthService,
|
|
3
|
+
ConsumerService,
|
|
4
|
+
ModelService,
|
|
5
|
+
SubscriptionService,
|
|
6
|
+
UsageService,
|
|
7
|
+
} from "../../generated/router/dashboard/v1";
|
|
8
|
+
import {
|
|
9
|
+
createAuthServiceClient,
|
|
10
|
+
createConsumerServiceClient,
|
|
11
|
+
createModelServiceClient,
|
|
12
|
+
createSubscriptionServiceClient,
|
|
13
|
+
createUsageServiceClient,
|
|
14
|
+
} from "../../generated/router/dashboard/v1";
|
|
15
|
+
import { requestJson } from "../http/request";
|
|
16
|
+
|
|
17
|
+
export type RequestType = {
|
|
18
|
+
path: string;
|
|
19
|
+
method: string;
|
|
20
|
+
body: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RequestHandler = (
|
|
24
|
+
request: RequestType,
|
|
25
|
+
meta: { service: string; method: string },
|
|
26
|
+
) => Promise<unknown>;
|
|
27
|
+
|
|
28
|
+
export type ClientFactories = {
|
|
29
|
+
createConsumerServiceClient: (handler: RequestHandler) => ConsumerService;
|
|
30
|
+
createAuthServiceClient: (handler: RequestHandler) => AuthService;
|
|
31
|
+
createSubscriptionServiceClient: (
|
|
32
|
+
handler: RequestHandler,
|
|
33
|
+
) => SubscriptionService;
|
|
34
|
+
createUsageServiceClient: (handler: RequestHandler) => UsageService;
|
|
35
|
+
createModelServiceClient: (handler: RequestHandler) => ModelService;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ApiClients = {
|
|
39
|
+
authService: AuthService;
|
|
40
|
+
consumerService: ConsumerService;
|
|
41
|
+
modelService: ModelService;
|
|
42
|
+
subscriptionService: SubscriptionService;
|
|
43
|
+
usageService: UsageService;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const createApiClients = ({
|
|
47
|
+
fetchImpl,
|
|
48
|
+
clients,
|
|
49
|
+
}: {
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
clients?: ClientFactories;
|
|
52
|
+
}): ApiClients => {
|
|
53
|
+
const factories =
|
|
54
|
+
clients ??
|
|
55
|
+
({
|
|
56
|
+
createConsumerServiceClient,
|
|
57
|
+
createAuthServiceClient,
|
|
58
|
+
createSubscriptionServiceClient,
|
|
59
|
+
createUsageServiceClient,
|
|
60
|
+
createModelServiceClient,
|
|
61
|
+
} satisfies ClientFactories);
|
|
62
|
+
|
|
63
|
+
const handler: RequestHandler = async ({ path, method, body }) => {
|
|
64
|
+
return requestJson({
|
|
65
|
+
path,
|
|
66
|
+
method,
|
|
67
|
+
body: body ? JSON.parse(body) : undefined,
|
|
68
|
+
fetchImpl,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
authService: factories.createAuthServiceClient(handler),
|
|
74
|
+
consumerService: factories.createConsumerServiceClient(handler),
|
|
75
|
+
modelService: factories.createModelServiceClient(handler),
|
|
76
|
+
subscriptionService: factories.createSubscriptionServiceClient(handler),
|
|
77
|
+
usageService: factories.createUsageServiceClient(handler),
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomInt } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
type AuthToken = {
|
|
5
|
+
accessToken: string | undefined;
|
|
6
|
+
refreshToken: string | undefined;
|
|
7
|
+
expiresAt: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type AuthorizeFn = (req: { code: string }) => Promise<AuthToken>;
|
|
11
|
+
|
|
12
|
+
type PollOptions = {
|
|
13
|
+
authorize: AuthorizeFn;
|
|
14
|
+
code: string;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
initialDelayMs?: number;
|
|
17
|
+
maxDelayMs?: number;
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
onRetry?: (attempt: number, delayMs: number) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
24
|
+
|
|
25
|
+
export const generateAuthCode = () => {
|
|
26
|
+
let out = "";
|
|
27
|
+
for (let i = 0; i < 13; i += 1) {
|
|
28
|
+
out += alphabet[randomInt(32)];
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const buildLoginUrl = (authCode: string) =>
|
|
34
|
+
`https://getrouter.dev/#/a/${authCode}`;
|
|
35
|
+
|
|
36
|
+
export const openLoginUrl = async (url: string) => {
|
|
37
|
+
try {
|
|
38
|
+
if (process.platform === "darwin") {
|
|
39
|
+
const child = spawn("open", [url], {
|
|
40
|
+
stdio: "ignore",
|
|
41
|
+
detached: true,
|
|
42
|
+
});
|
|
43
|
+
child.unref();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (process.platform === "win32") {
|
|
47
|
+
const child = spawn("cmd", ["/c", "start", "", url], {
|
|
48
|
+
stdio: "ignore",
|
|
49
|
+
detached: true,
|
|
50
|
+
});
|
|
51
|
+
child.unref();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const child = spawn("xdg-open", [url], {
|
|
55
|
+
stdio: "ignore",
|
|
56
|
+
detached: true,
|
|
57
|
+
});
|
|
58
|
+
child.unref();
|
|
59
|
+
} catch {
|
|
60
|
+
// best effort
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const pollAuthorize = async ({
|
|
65
|
+
authorize,
|
|
66
|
+
code,
|
|
67
|
+
timeoutMs = 5 * 60 * 1000,
|
|
68
|
+
initialDelayMs = 1000,
|
|
69
|
+
maxDelayMs = 10000,
|
|
70
|
+
sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)),
|
|
71
|
+
now = () => Date.now(),
|
|
72
|
+
onRetry,
|
|
73
|
+
}: PollOptions) => {
|
|
74
|
+
const start = now();
|
|
75
|
+
let delay = initialDelayMs;
|
|
76
|
+
let attempt = 0;
|
|
77
|
+
while (true) {
|
|
78
|
+
try {
|
|
79
|
+
return await authorize({ code });
|
|
80
|
+
} catch (err: unknown) {
|
|
81
|
+
const status =
|
|
82
|
+
typeof err === "object" && err !== null && "status" in err
|
|
83
|
+
? (err as { status?: number }).status
|
|
84
|
+
: undefined;
|
|
85
|
+
if (status === 404) {
|
|
86
|
+
// keep polling
|
|
87
|
+
} else if (status === 400) {
|
|
88
|
+
throw new Error("Auth code already used. Please log in again.");
|
|
89
|
+
} else if (status === 403) {
|
|
90
|
+
throw new Error("Auth code expired. Please log in again.");
|
|
91
|
+
} else {
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (now() - start >= timeoutMs) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"Login timed out. Please run getrouter auth login again.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
attempt += 1;
|
|
101
|
+
onRetry?.(attempt, delay);
|
|
102
|
+
await sleep(delay);
|
|
103
|
+
delay = Math.min(delay * 2, maxDelayMs);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readAuth, writeAuth } from "../config";
|
|
2
|
+
import { defaultAuthState } from "../config/types";
|
|
3
|
+
|
|
4
|
+
type AuthStatus = {
|
|
5
|
+
status: "logged_in" | "logged_out";
|
|
6
|
+
note?: string;
|
|
7
|
+
expiresAt?: string;
|
|
8
|
+
accessToken?: string;
|
|
9
|
+
refreshToken?: string;
|
|
10
|
+
tokenType?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const isExpired = (expiresAt: string) => {
|
|
14
|
+
if (!expiresAt) return true;
|
|
15
|
+
const t = Date.parse(expiresAt);
|
|
16
|
+
if (Number.isNaN(t)) return true;
|
|
17
|
+
return t <= Date.now();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getAuthStatus = (): AuthStatus => {
|
|
21
|
+
const auth = readAuth();
|
|
22
|
+
const hasTokens = Boolean(auth.accessToken && auth.refreshToken);
|
|
23
|
+
if (!hasTokens || isExpired(auth.expiresAt)) {
|
|
24
|
+
return { status: "logged_out" };
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
status: "logged_in",
|
|
28
|
+
expiresAt: auth.expiresAt,
|
|
29
|
+
accessToken: auth.accessToken,
|
|
30
|
+
refreshToken: auth.refreshToken,
|
|
31
|
+
tokenType: auth.tokenType,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const clearAuth = () => {
|
|
36
|
+
writeAuth(defaultAuthState());
|
|
37
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const readJsonFile = <T = unknown>(filePath: string): T | null => {
|
|
5
|
+
if (!fs.existsSync(filePath)) return null;
|
|
6
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
7
|
+
return JSON.parse(raw) as T;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const writeJsonFile = (filePath: string, value: unknown) => {
|
|
11
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
12
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
|
13
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readJsonFile, writeJsonFile } from "./fs";
|
|
5
|
+
import {
|
|
6
|
+
type AuthState,
|
|
7
|
+
type ConfigFile,
|
|
8
|
+
defaultAuthState,
|
|
9
|
+
defaultConfig,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const resolveConfigDir = () =>
|
|
13
|
+
process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
14
|
+
|
|
15
|
+
const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
|
|
16
|
+
const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
|
|
17
|
+
|
|
18
|
+
export const readConfig = (): ConfigFile => ({
|
|
19
|
+
...defaultConfig(),
|
|
20
|
+
...(readJsonFile<ConfigFile>(getConfigPath()) ?? {}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const writeConfig = (cfg: ConfigFile) =>
|
|
24
|
+
writeJsonFile(getConfigPath(), cfg);
|
|
25
|
+
|
|
26
|
+
export const readAuth = (): AuthState => ({
|
|
27
|
+
...defaultAuthState(),
|
|
28
|
+
...(readJsonFile<AuthState>(getAuthPath()) ?? {}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const writeAuth = (auth: AuthState) => {
|
|
32
|
+
const authPath = getAuthPath();
|
|
33
|
+
writeJsonFile(authPath, auth);
|
|
34
|
+
if (process.platform !== "win32") {
|
|
35
|
+
fs.chmodSync(authPath, 0o600);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const SECRET_KEYS = new Set(["accessToken", "refreshToken", "apiKey"]);
|
|
2
|
+
|
|
3
|
+
const mask = (value: string) => {
|
|
4
|
+
if (!value) return "";
|
|
5
|
+
if (value.length <= 8) return "****";
|
|
6
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const redactSecrets = <T extends Record<string, unknown>>(obj: T): T => {
|
|
10
|
+
const out: Record<string, unknown> = { ...obj };
|
|
11
|
+
for (const key of Object.keys(out)) {
|
|
12
|
+
const value = out[key];
|
|
13
|
+
if (SECRET_KEYS.has(key) && typeof value === "string") {
|
|
14
|
+
out[key] = mask(value);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out as T;
|
|
18
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ConfigFile = {
|
|
2
|
+
apiBase: string;
|
|
3
|
+
json: boolean;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type AuthState = {
|
|
7
|
+
accessToken: string;
|
|
8
|
+
refreshToken: string;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
tokenType: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const defaultConfig = (): ConfigFile => ({
|
|
14
|
+
apiBase: "https://getrouter.dev",
|
|
15
|
+
json: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const defaultAuthState = (): AuthState => ({
|
|
19
|
+
accessToken: "",
|
|
20
|
+
refreshToken: "",
|
|
21
|
+
expiresAt: "",
|
|
22
|
+
tokenType: "Bearer",
|
|
23
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ApiError = {
|
|
2
|
+
code?: string;
|
|
3
|
+
message: string;
|
|
4
|
+
details?: unknown;
|
|
5
|
+
status?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const createApiError = (
|
|
9
|
+
payload: unknown,
|
|
10
|
+
fallbackMessage: string,
|
|
11
|
+
status?: number,
|
|
12
|
+
) => {
|
|
13
|
+
const payloadObject =
|
|
14
|
+
payload && typeof payload === "object"
|
|
15
|
+
? (payload as Record<string, unknown>)
|
|
16
|
+
: undefined;
|
|
17
|
+
const message =
|
|
18
|
+
payloadObject && typeof payloadObject.message === "string"
|
|
19
|
+
? payloadObject.message
|
|
20
|
+
: fallbackMessage;
|
|
21
|
+
const err = new Error(message) as Error & ApiError;
|
|
22
|
+
if (payloadObject && typeof payloadObject.code === "string") {
|
|
23
|
+
err.code = payloadObject.code;
|
|
24
|
+
}
|
|
25
|
+
if (payloadObject && payloadObject.details != null) {
|
|
26
|
+
err.details = payloadObject.details;
|
|
27
|
+
}
|
|
28
|
+
if (typeof status === "number") {
|
|
29
|
+
err.status = status;
|
|
30
|
+
}
|
|
31
|
+
return err;
|
|
32
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readAuth } from "../config";
|
|
2
|
+
import { createApiError } from "./errors";
|
|
3
|
+
import { buildApiUrl } from "./url";
|
|
4
|
+
|
|
5
|
+
type RequestInput = {
|
|
6
|
+
path: string;
|
|
7
|
+
method: string;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getAuthCookieName = () =>
|
|
13
|
+
process.env.GETROUTER_AUTH_COOKIE ||
|
|
14
|
+
process.env.KRATOS_AUTH_COOKIE ||
|
|
15
|
+
"access_token";
|
|
16
|
+
|
|
17
|
+
export const requestJson = async <T = unknown>({
|
|
18
|
+
path,
|
|
19
|
+
method,
|
|
20
|
+
body,
|
|
21
|
+
fetchImpl,
|
|
22
|
+
}: RequestInput): Promise<T> => {
|
|
23
|
+
const headers: Record<string, string> = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
};
|
|
26
|
+
const auth = readAuth();
|
|
27
|
+
if (auth.accessToken) {
|
|
28
|
+
headers.Authorization = `Bearer ${auth.accessToken}`;
|
|
29
|
+
headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
|
|
30
|
+
}
|
|
31
|
+
const res = await (fetchImpl ?? fetch)(buildApiUrl(path), {
|
|
32
|
+
method,
|
|
33
|
+
headers,
|
|
34
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const payload = await res.json().catch(() => null);
|
|
38
|
+
throw createApiError(payload, res.statusText, res.status);
|
|
39
|
+
}
|
|
40
|
+
return (await res.json()) as T;
|
|
41
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readConfig } from "../config";
|
|
2
|
+
|
|
3
|
+
export const getApiBase = () => {
|
|
4
|
+
const raw = readConfig().apiBase || "";
|
|
5
|
+
return raw.replace(/\/+$/, "");
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const buildApiUrl = (path: string) => {
|
|
9
|
+
const base = getApiBase();
|
|
10
|
+
const normalized = path.replace(/^\/+/, "");
|
|
11
|
+
return base ? `${base}/${normalized}` : `/${normalized}`;
|
|
12
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
type ClipboardCommand = {
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type SpawnLike = (
|
|
9
|
+
command: string,
|
|
10
|
+
args: string[],
|
|
11
|
+
options: { stdio: ["pipe", "ignore", "ignore"] },
|
|
12
|
+
) => {
|
|
13
|
+
stdin: { write: (text: string) => void; end: () => void };
|
|
14
|
+
on: (event: string, cb: (code?: number) => void) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CopyOptions = {
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
spawnFn?: SpawnLike;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const getClipboardCommands = (
|
|
23
|
+
platform: NodeJS.Platform,
|
|
24
|
+
): ClipboardCommand[] => {
|
|
25
|
+
if (platform === "darwin") return [{ command: "pbcopy", args: [] }];
|
|
26
|
+
if (platform === "win32") return [{ command: "clip", args: [] }];
|
|
27
|
+
return [
|
|
28
|
+
{ command: "wl-copy", args: [] },
|
|
29
|
+
{ command: "xclip", args: ["-selection", "clipboard"] },
|
|
30
|
+
];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const runClipboardCommand = (
|
|
34
|
+
text: string,
|
|
35
|
+
command: ClipboardCommand,
|
|
36
|
+
spawnFn: SpawnLike,
|
|
37
|
+
): Promise<boolean> =>
|
|
38
|
+
new Promise((resolve) => {
|
|
39
|
+
const child = spawnFn(command.command, command.args, {
|
|
40
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
child.on("error", () => resolve(false));
|
|
43
|
+
child.on("close", (code) => resolve(code === 0));
|
|
44
|
+
child.stdin.write(text);
|
|
45
|
+
child.stdin.end();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const copyToClipboard = async (
|
|
49
|
+
text: string,
|
|
50
|
+
options: CopyOptions = {},
|
|
51
|
+
) => {
|
|
52
|
+
if (!text) return false;
|
|
53
|
+
const platform = options.platform ?? process.platform;
|
|
54
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
55
|
+
const commands = getClipboardCommands(platform);
|
|
56
|
+
for (const command of commands) {
|
|
57
|
+
const ok = await runClipboardCommand(text, command, spawnFn);
|
|
58
|
+
if (ok) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { FuzzyChoice } from "./fuzzy";
|
|
2
|
+
|
|
3
|
+
export type ReasoningChoice = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
description: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const MODEL_CHOICES: FuzzyChoice<string>[] = [
|
|
11
|
+
{
|
|
12
|
+
title: "gpt-5.2-codex",
|
|
13
|
+
value: "gpt-5.2-codex",
|
|
14
|
+
description: "Latest frontier agentic coding model.",
|
|
15
|
+
keywords: ["gpt-5.2-codex", "codex"],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
title: "gpt-5.1-codex-max",
|
|
19
|
+
value: "gpt-5.1-codex-max",
|
|
20
|
+
description: "Codex-optimized flagship for deep and fast reasoning.",
|
|
21
|
+
keywords: ["gpt-5.1-codex-max", "codex"],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: "gpt-5.1-codex-mini",
|
|
25
|
+
value: "gpt-5.1-codex-mini",
|
|
26
|
+
description: "Optimized for codex. Cheaper, faster, but less capable.",
|
|
27
|
+
keywords: ["gpt-5.1-codex-mini", "codex"],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
title: "gpt-5.2",
|
|
31
|
+
value: "gpt-5.2",
|
|
32
|
+
description:
|
|
33
|
+
"Latest frontier model with improvements across knowledge, reasoning and coding.",
|
|
34
|
+
keywords: ["gpt-5.2"],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export const REASONING_CHOICES: ReasoningChoice[] = [
|
|
39
|
+
{
|
|
40
|
+
id: "low",
|
|
41
|
+
label: "Low",
|
|
42
|
+
value: "low",
|
|
43
|
+
description: "Fast responses with lighter reasoning",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "medium",
|
|
47
|
+
label: "Medium (default)",
|
|
48
|
+
value: "medium",
|
|
49
|
+
description: "Balances speed and reasoning depth for everyday tasks",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "high",
|
|
53
|
+
label: "High",
|
|
54
|
+
value: "high",
|
|
55
|
+
description: "Greater reasoning depth for complex problems",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "extra_high",
|
|
59
|
+
label: "Extra high",
|
|
60
|
+
value: "xhigh",
|
|
61
|
+
description:
|
|
62
|
+
"Extra high reasoning depth for complex problems. Warning: Extra high reasoning effort can quickly consume Plus plan rate limits.",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const REASONING_FUZZY_CHOICES: FuzzyChoice<string>[] =
|
|
67
|
+
REASONING_CHOICES.map((choice) => ({
|
|
68
|
+
title: choice.label,
|
|
69
|
+
value: choice.id,
|
|
70
|
+
description: choice.description,
|
|
71
|
+
keywords: [choice.id, choice.value],
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
export const mapReasoningValue = (id: string) =>
|
|
75
|
+
REASONING_CHOICES.find((choice) => choice.id === id)?.value ?? "medium";
|