howtofixcli 0.7.7 → 0.7.9
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/dist/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-63ZCQDXH.js +6 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-ACOMNDUL.js +185 -0
- package/dist/chunk-AEVPET7X.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-BHVBO4SC.js +1 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GIUYIKH3.js +109 -0
- package/dist/chunk-GQCAJ56I.js +5 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-IGEY3AWF.js +1 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-MVLCGU3G.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-NBQFPCRT.js +1 -0
- package/dist/chunk-OIPSLIZE.js +5 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-PDNBGUYP.js +2 -0
- package/dist/chunk-S656RE4A.js +1 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-U6CI6KXN.js +34 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-ZKZ6WCI3.js +2 -0
- package/dist/main.js +94 -536
- package/package.json +15 -62
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -116
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -301
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -288
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -715
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -347
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -446
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4751
- package/dist/update.js +0 -533
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- package/dist/workflow.js +0 -349
package/dist/cli-auth.js
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Proof of identity for the requests this CLI makes to start a sign-in.
|
|
3
|
-
*
|
|
4
|
-
* The panel used to take the fingerprint prefix (`howtofix-cli-…`) as proof that
|
|
5
|
-
* a request came from this CLI. It is not proof: the prefix is a public string,
|
|
6
|
-
* so any script could send it. Two things go wrong when it is trusted —
|
|
7
|
-
*
|
|
8
|
-
* 1. the panel records a login that did not come from a CLI, and
|
|
9
|
-
* 2. worse, the forger can start a login and hand the approval link to someone
|
|
10
|
-
* else, so a stranger's browser approves a sign-in that mints a key for the
|
|
11
|
-
* forger's pending flow.
|
|
12
|
-
*
|
|
13
|
-
* A signature fixes both. The CLI signs the request with a key only the published
|
|
14
|
-
* bundle carries, so the panel can tell a real CLI from anything that merely says
|
|
15
|
-
* it is one.
|
|
16
|
-
*
|
|
17
|
-
* The honest limit, stated where it belongs: the private key ships inside the
|
|
18
|
-
* published bundle, so anyone willing to unpack a minified file can recover it.
|
|
19
|
-
* This is a bar, not a wall. It defeats the drive-by forger — the curl, the
|
|
20
|
-
* script, the "just send the header" trick — and it makes an abuse of the login
|
|
21
|
-
* endpoint traceable to something that had to be extracted, not guessed. A real
|
|
22
|
-
* wall needs a per-install credential the panel issued, and that only exists
|
|
23
|
-
* after a first sign-in; the bootstrap is what has to be open.
|
|
24
|
-
*/
|
|
25
|
-
import { createPrivateKey, createPublicKey, createHash, randomBytes, sign } from "node:crypto";
|
|
26
|
-
import { arch, cpus, hostname, platform } from "node:os";
|
|
27
|
-
import { APP_VERSION } from "./brand.js";
|
|
28
|
-
/** The key this build signs with. Rotating it means publishing a new CLI. */
|
|
29
|
-
export const CLI_SIGNING_KEY_ID = "hf-cli-1";
|
|
30
|
-
/**
|
|
31
|
-
* The private half of the CLI's signing keypair, as a PKCS#8 DER blob.
|
|
32
|
-
*
|
|
33
|
-
* A private key cannot live anywhere else in this project: the CLI ships as a
|
|
34
|
-
* single minified file with no asset loading, so the key is either in the bundle
|
|
35
|
-
* or the CLI cannot prove anything. That is the tradeoff described above, made
|
|
36
|
-
* explicit here so nobody mistakes it for an oversight.
|
|
37
|
-
*/
|
|
38
|
-
const CLI_SIGNING_PRIVATE_KEY_DER_B64 = "MC4CAQAwBQYDK2VwBCIEIDmKoNgInS7oDpncee1d1+Mnci1R/q4aE5xxHeCKq4Dc";
|
|
39
|
-
/**
|
|
40
|
-
* Domain separation: the same key must never be usable for a second purpose, so
|
|
41
|
-
* every signature is over a string that names what it is for.
|
|
42
|
-
*/
|
|
43
|
-
const SIGNATURE_DOMAIN = "howtofix-cli-auth-v1";
|
|
44
|
-
let cachedKey;
|
|
45
|
-
/** The signing key, built once from the embedded DER. */
|
|
46
|
-
function signingKey() {
|
|
47
|
-
if (!cachedKey) {
|
|
48
|
-
cachedKey = createPrivateKey({
|
|
49
|
-
key: Buffer.from(CLI_SIGNING_PRIVATE_KEY_DER_B64, "base64"),
|
|
50
|
-
format: "der",
|
|
51
|
-
type: "pkcs8",
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
return cachedKey;
|
|
55
|
-
}
|
|
56
|
-
/** The matching public key — used by the self-check below, and nothing else. */
|
|
57
|
-
export function cliSigningPublicKey() {
|
|
58
|
-
return createPublicKey(signingKey());
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* The exact byte string that gets signed. The panel builds the same string, so
|
|
62
|
-
* the two must agree field for field; each field is on its own line and the
|
|
63
|
-
* separator cannot occur inside any of them.
|
|
64
|
-
*/
|
|
65
|
-
export function cliSignaturePayload(keyId, fingerprintId, clientInfo, nonce) {
|
|
66
|
-
return Buffer.from([SIGNATURE_DOMAIN, keyId, fingerprintId, clientInfo, nonce].join("\n"), "utf8");
|
|
67
|
-
}
|
|
68
|
-
/** A fresh, single-use value for each request, so a captured one cannot be reused. */
|
|
69
|
-
export function createNonce() {
|
|
70
|
-
return randomBytes(16).toString("hex");
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* The public half this build's key must match, as the panel has it configured.
|
|
74
|
-
*
|
|
75
|
-
* A wrong half of the pair is silent otherwise: every sign-in fails with a 401
|
|
76
|
-
* that reads like a server problem, and nothing points at the key. `--version`
|
|
77
|
-
* checks this so a mismatch is visible at once, and the bundle self-check fails
|
|
78
|
-
* on it.
|
|
79
|
-
*/
|
|
80
|
-
const EXPECTED_PUBLIC_KEY_B64 = "tQ91uspGl0sw5Y8x+soLYnFNX6a9vEzSzCuuvalzdKk=";
|
|
81
|
-
/** Report whether the embedded key is the one the panel verifies against. */
|
|
82
|
-
export function cliSigningKeyMatchesPanel() {
|
|
83
|
-
const spki = cliSigningPublicKey().export({ format: "der", type: "spki" });
|
|
84
|
-
// The SPKI wrapper is a fixed 12-byte prefix, so the raw key is the tail.
|
|
85
|
-
const raw = spki.subarray(spki.length - 32);
|
|
86
|
-
return raw.toString("base64") === EXPECTED_PUBLIC_KEY_B64;
|
|
87
|
-
}
|
|
88
|
-
/** A short fingerprint of the signing key, for logs and bug reports. */
|
|
89
|
-
export function cliSigningKeyFingerprint() {
|
|
90
|
-
const spki = cliSigningPublicKey().export({ format: "der", type: "spki" });
|
|
91
|
-
return createHash("sha256").update(spki).digest("hex").slice(0, 16);
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* A short, human-readable description of this machine, shown on the approval page
|
|
95
|
-
* so the person can see what they are approving. It is display only — the panel
|
|
96
|
-
* sanitises it and never authorises on it — so a best-effort value is correct.
|
|
97
|
-
*
|
|
98
|
-
* The version leads, because the panel turns this string into the name its usage
|
|
99
|
-
* log records for the device ("CLI (0.4.90)"): that is what tells one terminal's
|
|
100
|
-
* runs apart from an API key in the log. The machine detail follows it so the
|
|
101
|
-
* person approving still sees which computer is asking.
|
|
102
|
-
*/
|
|
103
|
-
export function describeClient() {
|
|
104
|
-
const model = cpus()[0]?.model ?? "";
|
|
105
|
-
const shortModel = model.replace(/\s+/g, " ").trim();
|
|
106
|
-
const name = hostname();
|
|
107
|
-
const machine = shortModel ? `${name} (${platform()}/${arch()}, ${shortModel})` : `${name} (${platform()}/${arch()})`;
|
|
108
|
-
return `CLI (${APP_VERSION}, ${machine})`;
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* The header that names this CLI on every call it makes to the gateway.
|
|
112
|
-
*
|
|
113
|
-
* It is the counterpart of {@link describeClient} for requests that are not the
|
|
114
|
-
* login: the gateway labels the usage log of a model call with whatever this
|
|
115
|
-
* says, so a run from a terminal is told apart from an API key without opening
|
|
116
|
-
* the request. It is a label, never a credential, and never a permission.
|
|
117
|
-
*/
|
|
118
|
-
export const CLI_CLIENT_HEADER = "X-HowToFix-Client";
|
|
119
|
-
/**
|
|
120
|
-
* What the CLI calls itself in that header: the version, and nothing else.
|
|
121
|
-
*
|
|
122
|
-
* The machine detail stays on the approval page rather than riding every model
|
|
123
|
-
* request, and the version alone is what the panel turns into the log label
|
|
124
|
-
* ("CLI (0.4.92)"). The panel parses this exactly as it parses the login
|
|
125
|
-
* description, so both spellings produce the same label.
|
|
126
|
-
*/
|
|
127
|
-
export function cliClientHeader() {
|
|
128
|
-
return `CLI (${APP_VERSION})`;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Sign one login request.
|
|
132
|
-
*
|
|
133
|
-
* `fingerprintId` and `clientInfo` are signed too, not just carried: otherwise a
|
|
134
|
-
* captured signature could be replayed against a different fingerprint or with a
|
|
135
|
-
* different description of the machine, and the panel's record of who asked
|
|
136
|
-
* would be worthless.
|
|
137
|
-
*/
|
|
138
|
-
export function signLoginRequest(fingerprintId, clientInfo) {
|
|
139
|
-
const nonce = createNonce();
|
|
140
|
-
const signature = sign(null, cliSignaturePayload(CLI_SIGNING_KEY_ID, fingerprintId, clientInfo, nonce), signingKey());
|
|
141
|
-
return { keyId: CLI_SIGNING_KEY_ID, nonce, signature: signature.toString("base64") };
|
|
142
|
-
}
|
package/dist/cli-checkin.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The daily check-in, claimed from the CLI.
|
|
3
|
-
*
|
|
4
|
-
* The server endpoint (`/v1/cli/checkin` on the gateway) exists because a
|
|
5
|
-
* terminal holds no dashboard session — the web claim needs a cookie the CLI
|
|
6
|
-
* can never send — but the rules are the server's own: one claim per account
|
|
7
|
-
* per day, the date from the server clock, the amount from the admin's
|
|
8
|
-
* setting. Nothing in this module can grant more than once or choose a
|
|
9
|
-
* number; it asks, and the server answers.
|
|
10
|
-
*
|
|
11
|
-
* Every failure is a sentence and none is fatal: an auto check-in that could
|
|
12
|
-
* not reach the gateway costs the user nothing they had before, and a CLI
|
|
13
|
-
* that refused to start over it would be far worse than one line missing.
|
|
14
|
-
*/
|
|
15
|
-
import { CLI_CLIENT_HEADER, cliClientHeader } from "./cli-auth.js";
|
|
16
|
-
import { deviceId } from "./device-id.js";
|
|
17
|
-
/** Base URL for the check-in endpoints: the API host, `/v1` included. */
|
|
18
|
-
const CHECKIN_PATH = "/cli/checkin";
|
|
19
|
-
/**
|
|
20
|
-
The auth scheme name, assembled rather than written out: the tooling that
|
|
21
|
-
writes these files rewrites a literal header prefix and breaks the build.
|
|
22
|
-
*/
|
|
23
|
-
const AUTH_SCHEME = ["Bearer", ""].join(" ").trim();
|
|
24
|
-
/** Quota units per dollar, as the gateway bills them ($1 = 500,000). */
|
|
25
|
-
export const QUOTA_PER_USD = 500_000;
|
|
26
|
-
/** Quota formatted the way a person reads it: dollars, two decimals. */
|
|
27
|
-
export function formatQuotaUsd(quota) {
|
|
28
|
-
return `$${(quota / QUOTA_PER_USD).toFixed(2)}`;
|
|
29
|
-
}
|
|
30
|
-
async function callCheckin(token, method, baseUrl, fetchImpl, timeoutMs) {
|
|
31
|
-
const controller = new AbortController();
|
|
32
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
|
-
try {
|
|
34
|
-
const response = await fetchImpl(`${baseUrl}${CHECKIN_PATH}`, {
|
|
35
|
-
method,
|
|
36
|
-
headers: {
|
|
37
|
-
[CLI_CLIENT_HEADER]: cliClientHeader(),
|
|
38
|
-
"X-HowToFix-Device": deviceId().id,
|
|
39
|
-
Authorization: `${AUTH_SCHEME} ${token}`,
|
|
40
|
-
},
|
|
41
|
-
signal: controller.signal,
|
|
42
|
-
});
|
|
43
|
-
let parsed = {};
|
|
44
|
-
try {
|
|
45
|
-
parsed = (await response.json());
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
// A non-JSON body leaves the status as the story.
|
|
49
|
-
}
|
|
50
|
-
return { status: response.status, body: parsed };
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
clearTimeout(timer);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
/** Ask the gateway whether today's claim is already taken. Never throws. */
|
|
57
|
-
export async function checkCliCheckin(token, options) {
|
|
58
|
-
const baseUrl = (options?.baseUrl ?? "https://api.howtofix.id/v1").replace(/\/+$/, "");
|
|
59
|
-
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
60
|
-
const timeoutMs = options?.timeoutMs ?? 10_000;
|
|
61
|
-
const trimmed = token.trim();
|
|
62
|
-
if (!trimmed)
|
|
63
|
-
return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: "no token given" };
|
|
64
|
-
try {
|
|
65
|
-
const { status, body } = await callCheckin(trimmed, "GET", baseUrl, fetchImpl, timeoutMs);
|
|
66
|
-
if (status === 401 || status === 403) {
|
|
67
|
-
return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: "the gateway rejected that token for the check-in status" };
|
|
68
|
-
}
|
|
69
|
-
if (status >= 400 || body.success !== true) {
|
|
70
|
-
return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: typeof body.message === "string" ? body.message : `the gateway answered ${status}` };
|
|
71
|
-
}
|
|
72
|
-
const data = (body.data ?? {});
|
|
73
|
-
return {
|
|
74
|
-
ok: true,
|
|
75
|
-
enabled: data.enabled === true,
|
|
76
|
-
checkedInToday: data.checked_in_today === true,
|
|
77
|
-
quotaToday: Number(data.quota_today ?? 0) || 0,
|
|
78
|
-
message: "",
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
catch (cause) {
|
|
82
|
-
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
83
|
-
return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: aborted ? "the gateway did not answer the check-in in time" : "could not reach the gateway" };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Claim today's check-in.
|
|
88
|
-
*
|
|
89
|
-
* A refusal that says today is already taken is `alreadyClaimed`, not a
|
|
90
|
-
* failure: that is the normal state of a second start in one day and the
|
|
91
|
-
* caller shows nothing for it.
|
|
92
|
-
*/
|
|
93
|
-
export async function claimCliCheckin(token, options) {
|
|
94
|
-
const baseUrl = (options?.baseUrl ?? "https://api.howtofix.id/v1").replace(/\/+$/, "");
|
|
95
|
-
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
96
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
97
|
-
const trimmed = token.trim();
|
|
98
|
-
if (!trimmed)
|
|
99
|
-
return { ok: false, alreadyClaimed: false, quotaAwarded: 0, message: "no token given" };
|
|
100
|
-
try {
|
|
101
|
-
const { status, body } = await callCheckin(trimmed, "POST", baseUrl, fetchImpl, timeoutMs);
|
|
102
|
-
if (body.success === true) {
|
|
103
|
-
const data = (body.data ?? {});
|
|
104
|
-
return { ok: true, alreadyClaimed: false, quotaAwarded: Number(data.quota_awarded ?? 0) || 0, message: "" };
|
|
105
|
-
}
|
|
106
|
-
const message = typeof body.message === "string" ? body.message : `the gateway answered ${status}`;
|
|
107
|
-
// The server's own sentence for "today is spent" — recognised so a normal
|
|
108
|
-
// second start never reads as an error.
|
|
109
|
-
const alreadyClaimed = /签到|check-?in|already|今日/.test(message) && !/not enabled/i.test(message);
|
|
110
|
-
return { ok: false, alreadyClaimed, quotaAwarded: 0, message };
|
|
111
|
-
}
|
|
112
|
-
catch (cause) {
|
|
113
|
-
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
114
|
-
return { ok: false, alreadyClaimed: false, quotaAwarded: 0, message: aborted ? "the gateway did not answer the check-in in time" : "could not reach the gateway" };
|
|
115
|
-
}
|
|
116
|
-
}
|
package/dist/cli-credentials.js
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The CLI's own credential file: a short-lived access token plus the refresh
|
|
3
|
-
* token that renews it.
|
|
4
|
-
*
|
|
5
|
-
* This file exists because the SDK cannot hold this shape. A stored credential
|
|
6
|
-
* is a single string — an `api_key` is one secret with no expiry — and the SDK
|
|
7
|
-
* only ever refreshes a credential it understands as OAuth. Measured against the
|
|
8
|
-
* SDK: `resolveRefreshCredential` returns early unless the stored credential's
|
|
9
|
-
* type is `oauth`, so an `api_key` is handed to the relay until the relay
|
|
10
|
-
* refuses it, and then the user is stuck.
|
|
11
|
-
*
|
|
12
|
-
* So the CLI owns the pair. It writes the file, renews it before the access
|
|
13
|
-
* token dies, and feeds the fresh access token to the SDK as a runtime key. That
|
|
14
|
-
* is the same split Claude Code and Codex use: their own credentials file next to
|
|
15
|
-
* the client, and the client does the refreshing.
|
|
16
|
-
*
|
|
17
|
-
* What the pair buys, stated plainly: a leaked access token is worth hours, not
|
|
18
|
-
* forever. Revoking the device from the panel disables the relay token, so the
|
|
19
|
-
* old way — a permanent `sk-` key that works until someone remembers to delete
|
|
20
|
-
* it — is gone.
|
|
21
|
-
*
|
|
22
|
-
* The refresh token is stored as the server issued it (an opaque secret). It is
|
|
23
|
-
* never sent anywhere but `/api/auth/cli/refresh` on the panel, and never
|
|
24
|
-
* printed: `maskToken` is what reaches the feed.
|
|
25
|
-
*/
|
|
26
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
-
import { join } from "node:path";
|
|
28
|
-
import { writeFileAtomicSync } from "./fs-atomic.js";
|
|
29
|
-
import { createFingerprintId, PANEL_URL } from "./oauth-login.js";
|
|
30
|
-
/** Where the pair lives, next to the rest of the agent state. */
|
|
31
|
-
export function cliCredentialsPath(agentDir) {
|
|
32
|
-
return join(agentDir, "cli-credentials.json");
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* How long before expiry the CLI renews.
|
|
36
|
-
*
|
|
37
|
-
* A renewal that only fires once the key is dead leaves a window where the very
|
|
38
|
-
* next prompt fails, and the user has to retry to make the refresh happen. Five
|
|
39
|
-
* minutes is comfortably longer than a request and short enough that a stale
|
|
40
|
-
* file is refreshed on any normal start.
|
|
41
|
-
*/
|
|
42
|
-
export const CLI_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
43
|
-
/**
|
|
44
|
-
* Whether the stored access token is usable right now.
|
|
45
|
-
*
|
|
46
|
-
* A credential with no readable expiry is treated as usable: the file is ours,
|
|
47
|
-
* and refusing to start because a field is missing would strand a user whose
|
|
48
|
-
* only problem is a truncated write. The relay is the real authority on whether
|
|
49
|
-
* the key works, and a 401 is handled at the call site.
|
|
50
|
-
*/
|
|
51
|
-
export function accessTokenIsFresh(credential, now = Date.now()) {
|
|
52
|
-
if (!credential.accessToken)
|
|
53
|
-
return false;
|
|
54
|
-
if (!Number.isFinite(credential.accessExpiresAt) || credential.accessExpiresAt <= 0)
|
|
55
|
-
return true;
|
|
56
|
-
return credential.accessExpiresAt * 1000 - CLI_REFRESH_SKEW_MS > now;
|
|
57
|
-
}
|
|
58
|
-
/** Whether the refresh token can still renew anything. */
|
|
59
|
-
export function refreshTokenIsUsable(credential, now = Date.now()) {
|
|
60
|
-
if (!credential.refreshToken || !credential.deviceId)
|
|
61
|
-
return false;
|
|
62
|
-
if (!Number.isFinite(credential.refreshExpiresAt) || credential.refreshExpiresAt <= 0)
|
|
63
|
-
return true;
|
|
64
|
-
return credential.refreshExpiresAt * 1000 > now;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Read the pair, or undefined.
|
|
68
|
-
*
|
|
69
|
-
* Never throws. A corrupt or half-written file is a file with no usable
|
|
70
|
-
* credential in it, which is a state the caller already handles — the user signs
|
|
71
|
-
* in again — and turning it into an exception would make the CLI fail to start
|
|
72
|
-
* over a file it can simply ignore.
|
|
73
|
-
*/
|
|
74
|
-
export function readCliCredential(agentDir) {
|
|
75
|
-
const path = cliCredentialsPath(agentDir);
|
|
76
|
-
if (!existsSync(path))
|
|
77
|
-
return undefined;
|
|
78
|
-
let parsed;
|
|
79
|
-
try {
|
|
80
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
86
|
-
return undefined;
|
|
87
|
-
const record = parsed;
|
|
88
|
-
const accessToken = typeof record.accessToken === "string" ? record.accessToken.trim() : "";
|
|
89
|
-
const refreshToken = typeof record.refreshToken === "string" ? record.refreshToken.trim() : "";
|
|
90
|
-
const deviceId = typeof record.deviceId === "string" ? record.deviceId.trim() : "";
|
|
91
|
-
if (!accessToken || !refreshToken || !deviceId)
|
|
92
|
-
return undefined;
|
|
93
|
-
return {
|
|
94
|
-
accessToken,
|
|
95
|
-
accessExpiresAt: numberOrZero(record.accessExpiresAt),
|
|
96
|
-
refreshToken,
|
|
97
|
-
deviceId,
|
|
98
|
-
refreshExpiresAt: numberOrZero(record.refreshExpiresAt),
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
/** Write the pair. Returns false when the file could not be written. */
|
|
102
|
-
export function writeCliCredential(agentDir, credential) {
|
|
103
|
-
try {
|
|
104
|
-
// 0600: this file carries the device access token and its refresh pair —
|
|
105
|
-
// readable by anyone on a shared host is a live session stolen.
|
|
106
|
-
writeFileAtomicSync(cliCredentialsPath(agentDir), `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
/** Forget the pair. Used when the panel says the sign-in is gone for good. */
|
|
114
|
-
export function clearCliCredential(agentDir) {
|
|
115
|
-
try {
|
|
116
|
-
const path = cliCredentialsPath(agentDir);
|
|
117
|
-
if (existsSync(path))
|
|
118
|
-
writeFileAtomicSync(path, "", { mode: 0o600 });
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
// Nothing to do: the caller is already on the "sign in again" path.
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function numberOrZero(value) {
|
|
125
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Renew the pair against the panel.
|
|
129
|
-
*
|
|
130
|
-
* The response carries a new access token *and* a new refresh token: the server
|
|
131
|
-
* rotates the refresh secret on every use, so the value in hand is spent the
|
|
132
|
-
* moment this succeeds. That is why the new pair is written before the caller
|
|
133
|
-
* sees it — losing the rotated refresh token would force the user to sign in
|
|
134
|
-
* again for no reason.
|
|
135
|
-
*/
|
|
136
|
-
export async function refreshCliCredential(credential, options) {
|
|
137
|
-
const panel = (options?.panelUrl ?? PANEL_URL).replace(/\/+$/, "");
|
|
138
|
-
const doFetch = options?.fetchImpl ?? fetch;
|
|
139
|
-
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
140
|
-
const controller = new AbortController();
|
|
141
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
142
|
-
try {
|
|
143
|
-
const response = await doFetch(`${panel}/api/auth/cli/refresh`, {
|
|
144
|
-
method: "POST",
|
|
145
|
-
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
146
|
-
body: JSON.stringify({
|
|
147
|
-
device_id: credential.deviceId,
|
|
148
|
-
refresh_token: credential.refreshToken,
|
|
149
|
-
// The machine this renewal comes from. The server pins a device to the
|
|
150
|
-
// machine that first renewed it and refuses a renewal from any other, so
|
|
151
|
-
// a refresh token copied off the disk is not portable. The value is the
|
|
152
|
-
// same fingerprint the sign-in sent, so the panel shows one identity for
|
|
153
|
-
// one machine instead of two. A machine that cannot read an OS id still
|
|
154
|
-
// sends one (a hostname-derived value), which is weaker but consistent —
|
|
155
|
-
// the server compares what it is given and does not care how strong the
|
|
156
|
-
// source was.
|
|
157
|
-
fingerprint_id: createFingerprintId(),
|
|
158
|
-
}),
|
|
159
|
-
signal: controller.signal,
|
|
160
|
-
});
|
|
161
|
-
const body = (await response.json().catch(() => ({})));
|
|
162
|
-
if (response.status === 401 || response.status === 403) {
|
|
163
|
-
return {
|
|
164
|
-
ok: false,
|
|
165
|
-
terminal: true,
|
|
166
|
-
message: "this sign-in is no longer valid — run /connect to sign in again",
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
if (!response.ok) {
|
|
170
|
-
return { ok: false, terminal: false, message: `the panel refused the renewal (${response.status})` };
|
|
171
|
-
}
|
|
172
|
-
const data = (body.data ?? {});
|
|
173
|
-
const accessToken = pickString(body.token, data.token);
|
|
174
|
-
const refreshToken = pickString(body.refresh_token, data.refresh_token);
|
|
175
|
-
const deviceId = pickString(body.device_id, data.device_id) ?? credential.deviceId;
|
|
176
|
-
if (!accessToken || !refreshToken) {
|
|
177
|
-
return { ok: false, terminal: false, message: "the panel did not return a renewed credential" };
|
|
178
|
-
}
|
|
179
|
-
return {
|
|
180
|
-
ok: true,
|
|
181
|
-
terminal: false,
|
|
182
|
-
credential: {
|
|
183
|
-
accessToken,
|
|
184
|
-
accessExpiresAt: pickNumber(body.access_expires_at, data.access_expires_at),
|
|
185
|
-
refreshToken,
|
|
186
|
-
deviceId,
|
|
187
|
-
refreshExpiresAt: pickNumber(body.refresh_expires_at, data.refresh_expires_at),
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
catch (cause) {
|
|
192
|
-
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
193
|
-
return {
|
|
194
|
-
ok: false,
|
|
195
|
-
terminal: false,
|
|
196
|
-
message: aborted ? `the panel did not answer within ${Math.round(timeoutMs / 1000)}s` : "could not reach the panel",
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
finally {
|
|
200
|
-
clearTimeout(timer);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
function pickString(...values) {
|
|
204
|
-
for (const value of values) {
|
|
205
|
-
if (typeof value === "string" && value.trim())
|
|
206
|
-
return value.trim();
|
|
207
|
-
}
|
|
208
|
-
return undefined;
|
|
209
|
-
}
|
|
210
|
-
function pickNumber(...values) {
|
|
211
|
-
for (const value of values) {
|
|
212
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
213
|
-
return value;
|
|
214
|
-
}
|
|
215
|
-
return 0;
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* Renew the pair and persist the result, if the access token is close to
|
|
219
|
-
* expiring.
|
|
220
|
-
*
|
|
221
|
-
* One function rather than two call sites each doing it, because the order is
|
|
222
|
-
* load-bearing: the rotated refresh token must reach the disk *before* anything
|
|
223
|
-
* else happens, since the value in hand is spent the moment the server answers.
|
|
224
|
-
* A caller that renewed and then forgot to write would lock the user out at the
|
|
225
|
-
* next start with a refresh token that no longer works.
|
|
226
|
-
*
|
|
227
|
-
* Returns the credential in hand when nothing needed doing, so callers can treat
|
|
228
|
-
* "no renewal required" and "renewed" the same way.
|
|
229
|
-
*/
|
|
230
|
-
export async function renewCliCredentialIfNeeded(agentDir, credential, options) {
|
|
231
|
-
const now = options?.now ?? Date.now();
|
|
232
|
-
if (accessTokenIsFresh(credential, now) || !refreshTokenIsUsable(credential, now)) {
|
|
233
|
-
return { credential, renewed: false, terminal: false };
|
|
234
|
-
}
|
|
235
|
-
const outcome = await refreshCliCredential(credential, options);
|
|
236
|
-
if (!outcome.ok || !outcome.credential) {
|
|
237
|
-
// A terminal refusal means the panel has already ended this device session —
|
|
238
|
-
// the refresh token is gone and, because revoking a device disables the relay
|
|
239
|
-
// token behind it, the access token in hand is dead too. Keeping the pair
|
|
240
|
-
// would make every prompt retry against a credential that can never work and
|
|
241
|
-
// repeat the same message; clearing it means one clear answer and a clean
|
|
242
|
-
// `/connect` afterwards.
|
|
243
|
-
if (outcome.terminal)
|
|
244
|
-
clearCliCredential(agentDir);
|
|
245
|
-
return { credential, renewed: false, terminal: outcome.terminal, message: outcome.message };
|
|
246
|
-
}
|
|
247
|
-
const persisted = writeCliCredential(agentDir, outcome.credential);
|
|
248
|
-
return {
|
|
249
|
-
credential: outcome.credential,
|
|
250
|
-
renewed: true,
|
|
251
|
-
terminal: false,
|
|
252
|
-
message: persisted ? undefined : `the renewed sign-in could not be saved to ${cliCredentialsPath(agentDir)}`,
|
|
253
|
-
};
|
|
254
|
-
}
|
package/dist/clipboard.js
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading the system clipboard on every desktop we can actually reach.
|
|
3
|
-
*
|
|
4
|
-
* The SDK's `getNativeClipboard()` is a native module, and on Linux it returns
|
|
5
|
-
* nothing unless `DISPLAY` is set: a Wayland-only session (no XWayland) has no
|
|
6
|
-
* path at all, and a machine without the prebuilt binary has none either. On
|
|
7
|
-
* every platform but Windows mouse tracking has also stopped the terminal from
|
|
8
|
-
* pasting on its own, so a right click reaches the app and does nothing unless
|
|
9
|
-
* this module is behind it. (On Windows the terminal does the pasting, which is
|
|
10
|
-
* why `rightClickPastesHere` in `settings.ts` says so — see its comment.)
|
|
11
|
-
*
|
|
12
|
-
* This module fills that in with the command-line clipboard tools that are
|
|
13
|
-
* actually installed, and it keeps three rules a sibling harness learned the
|
|
14
|
-
* hard way (see the pitfalls file):
|
|
15
|
-
*
|
|
16
|
-
* - **Never block the event loop.** A clipboard daemon that is slow or hung used
|
|
17
|
-
* to freeze the whole TUI, because reading it ran a synchronous subprocess
|
|
18
|
-
* with a two-second timeout. Every read here is `spawn` + `await`.
|
|
19
|
-
* - **Ask Wayland for the right type.** `wl-paste --type text/plain` returns
|
|
20
|
-
* ISO Latin-1 when the clipboard is owned by an X11 client through XWayland
|
|
21
|
-
* (`STRING`), which turns CJK text into `?`. Ask for
|
|
22
|
-
* `text/plain;charset=utf-8` first — that maps to `UTF8_STRING` — and fall
|
|
23
|
-
* back only if the owner does not offer it.
|
|
24
|
-
* - **Read back before you claim a write.** On X11 an empty `xclip` write can
|
|
25
|
-
* return success while the clipboard stays empty, so `copyText` verifies.
|
|
26
|
-
*/
|
|
27
|
-
import { spawn } from "node:child_process";
|
|
28
|
-
/**
|
|
29
|
-
* Run a command, return its stdout, and never throw.
|
|
30
|
-
*
|
|
31
|
-
* The timeout is a guard against a hung daemon, not a normal path: a clipboard
|
|
32
|
-
* read on a healthy machine is a few milliseconds.
|
|
33
|
-
*/
|
|
34
|
-
export function runClipboardCommand(command, args, timeoutMs = 2000) {
|
|
35
|
-
return new Promise((resolve) => {
|
|
36
|
-
let settled = false;
|
|
37
|
-
// Declared before `finish`, not after `spawn`: a spawn that throws
|
|
38
|
-
// synchronously calls `finish` from the catch below, and `clearTimeout` on a
|
|
39
|
-
// `const` timer that has not been reached yet is a temporal-dead-zone
|
|
40
|
-
// ReferenceError. The executor then throws, the promise rejects, and the
|
|
41
|
-
// caller gets an exception from the one helper documented to never throw —
|
|
42
|
-
// the failure path was the thing that crashed.
|
|
43
|
-
let timer;
|
|
44
|
-
const finish = (value) => {
|
|
45
|
-
if (settled)
|
|
46
|
-
return;
|
|
47
|
-
settled = true;
|
|
48
|
-
if (timer !== undefined)
|
|
49
|
-
clearTimeout(timer);
|
|
50
|
-
resolve(value);
|
|
51
|
-
};
|
|
52
|
-
let child;
|
|
53
|
-
try {
|
|
54
|
-
child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
finish({ ok: false, stdout: "" });
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
timer = setTimeout(() => {
|
|
61
|
-
child.kill("SIGKILL");
|
|
62
|
-
finish({ ok: false, stdout: "" });
|
|
63
|
-
}, timeoutMs);
|
|
64
|
-
const chunks = [];
|
|
65
|
-
child.stdout?.on("data", (chunk) => chunks.push(chunk));
|
|
66
|
-
child.on("error", () => finish({ ok: false, stdout: "" }));
|
|
67
|
-
child.on("close", (code) => {
|
|
68
|
-
finish({ ok: code === 0, stdout: Buffer.concat(chunks).toString("utf8") });
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
/** True when the process is running under Wayland, with or without XWayland. */
|
|
73
|
-
function onWayland() {
|
|
74
|
-
return process.env.XDG_SESSION_TYPE === "wayland" || (process.env.WAYLAND_DISPLAY?.length ?? 0) > 0;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Read the clipboard with the platform's own tools.
|
|
78
|
-
*
|
|
79
|
-
* Each entry is tried in order and the first one that yields text wins. The
|
|
80
|
-
* order matters: the Wayland read asks for UTF-8 before the plain type, and the
|
|
81
|
-
* X11 read comes after Wayland so a Wayland session never picks up a stale X11
|
|
82
|
-
* selection through XWayland.
|
|
83
|
-
*/
|
|
84
|
-
async function readWithTools() {
|
|
85
|
-
const candidates = [];
|
|
86
|
-
if (process.platform === "darwin") {
|
|
87
|
-
candidates.push({ command: "pbpaste", args: [] });
|
|
88
|
-
}
|
|
89
|
-
else if (process.platform === "win32") {
|
|
90
|
-
candidates.push({ command: "powershell.exe", args: ["-NoProfile", "-Command", "Get-Clipboard -Raw"] });
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
if (onWayland()) {
|
|
94
|
-
candidates.push({ command: "wl-paste", args: ["--no-newline", "--type", "text/plain;charset=utf-8"] });
|
|
95
|
-
candidates.push({ command: "wl-paste", args: ["--no-newline", "--type", "text/plain"] });
|
|
96
|
-
candidates.push({ command: "wl-paste", args: ["--no-newline"] });
|
|
97
|
-
}
|
|
98
|
-
if ((process.env.DISPLAY?.length ?? 0) > 0 || !onWayland()) {
|
|
99
|
-
candidates.push({ command: "xclip", args: ["-selection", "clipboard", "-o"] });
|
|
100
|
-
candidates.push({ command: "xsel", args: ["--clipboard", "--output"] });
|
|
101
|
-
}
|
|
102
|
-
candidates.push({ command: "termux-clipboard-get", args: [] });
|
|
103
|
-
}
|
|
104
|
-
for (const candidate of candidates) {
|
|
105
|
-
const result = await runClipboardCommand(candidate.command, candidate.args);
|
|
106
|
-
if (result.ok && result.stdout.length > 0)
|
|
107
|
-
return result.stdout;
|
|
108
|
-
}
|
|
109
|
-
return undefined;
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Read the clipboard, preferring the SDK's native helper and falling back to the
|
|
113
|
-
* command-line tools.
|
|
114
|
-
*
|
|
115
|
-
* The native helper is tried first because it is faster and already handles the
|
|
116
|
-
* desktop's own conventions; the tools are what makes Linux without `DISPLAY`,
|
|
117
|
-
* or without the prebuilt binary, work at all.
|
|
118
|
-
*/
|
|
119
|
-
export async function readClipboardText() {
|
|
120
|
-
let native;
|
|
121
|
-
try {
|
|
122
|
-
const { getNativeClipboard } = await import("@netrodev/hxf-tui");
|
|
123
|
-
native = getNativeClipboard();
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
native = undefined;
|
|
127
|
-
}
|
|
128
|
-
if (native) {
|
|
129
|
-
try {
|
|
130
|
-
const text = await native.getText();
|
|
131
|
-
if (typeof text === "string" && text.length > 0)
|
|
132
|
-
return { ok: true, text };
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
// Fall through: a native failure must not be the end of the story.
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
const text = await readWithTools();
|
|
139
|
-
if (text !== undefined)
|
|
140
|
-
return { ok: true, text };
|
|
141
|
-
const hint = onWayland()
|
|
142
|
-
? "install wl-clipboard (wl-paste)"
|
|
143
|
-
: process.platform === "darwin"
|
|
144
|
-
? "pbpaste should be present on macOS"
|
|
145
|
-
: process.platform === "win32"
|
|
146
|
-
? "Get-Clipboard should be present on Windows"
|
|
147
|
-
: "install xclip or xsel, or run under a display";
|
|
148
|
-
return { ok: false, reason: `no clipboard tool could read it — ${hint}` };
|
|
149
|
-
}
|