@heyditto/cli 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -7
- package/dist/agents/launch.d.ts +5 -0
- package/dist/agents/launch.js +124 -22
- package/dist/agents/launch.js.map +1 -1
- package/dist/api.d.ts +69 -1
- package/dist/api.js +53 -9
- package/dist/api.js.map +1 -1
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +17 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.js +42 -33
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +66 -1
- package/dist/commands.js +490 -9
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +14 -1
- package/dist/config.js.map +1 -1
- package/dist/device-login.d.ts +21 -5
- package/dist/device-login.js +17 -8
- package/dist/device-login.js.map +1 -1
- package/dist/endpoint-format.d.ts +9 -0
- package/dist/endpoint-format.js +21 -0
- package/dist/endpoint-format.js.map +1 -0
- package/dist/gh-secret.d.ts +31 -0
- package/dist/gh-secret.js +94 -0
- package/dist/gh-secret.js.map +1 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.js +47 -0
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
1
2
|
import { Option } from "commander";
|
|
2
|
-
import { launchHarness } from "./agents/launch.js";
|
|
3
|
+
import { launchHarness, pickEndpoint } from "./agents/launch.js";
|
|
3
4
|
import { listSessions, removeSession } from "./agents/sessions.js";
|
|
4
|
-
import { HARNESSES, KEY_EXPIRIES } from "./agents/types.js";
|
|
5
|
-
import { listChatAgents, listEndpoints } from "./api.js";
|
|
5
|
+
import { HARNESSES, KEY_EXPIRIES, apiRootOf } from "./agents/types.js";
|
|
6
|
+
import { createEndpoint, createKey, deleteEndpoint, findEndpoint, getEndpoint, isEndpointPending, listChatAgents, listEndpoints, listKeys, revokeKey, updateEndpoint, } from "./api.js";
|
|
7
|
+
import { openInBrowser } from "./browser.js";
|
|
8
|
+
import { endpointURL } from "./config.js";
|
|
9
|
+
import { activationLink, formatActivation } from "./endpoint-format.js";
|
|
10
|
+
import { describeTarget, preflightGh, resolveRepoFromCwd, setGitHubSecret, validateRepo, validateSecretName, } from "./gh-secret.js";
|
|
6
11
|
import { SESSION_ENV, SESSION_ID_HEADER, endSession, readSessionHistory, resolveActiveSession, startSession, useSession, } from "./mcp-session.js";
|
|
7
12
|
import { readStoredAuth, updateStoredAuth } from "./store.js";
|
|
8
13
|
function pad(s, n) {
|
|
@@ -14,6 +19,58 @@ function spendColumn(e) {
|
|
|
14
19
|
return `${used} / ∞`;
|
|
15
20
|
return `${used} / ${e.spendLimitTokens.toLocaleString()}${e.spendPeriod && e.spendPeriod !== "never" ? ` ${e.spendPeriod}` : ""}`;
|
|
16
21
|
}
|
|
22
|
+
function isJSON(options) {
|
|
23
|
+
return options.output === "json" || options.output === "raw";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Endpoint controls spend the user's credits, so anything destructive asks
|
|
27
|
+
* the operator to type the slug back. `--yes` skips it for scripts; without a
|
|
28
|
+
* terminal and without `--yes` the command refuses.
|
|
29
|
+
*/
|
|
30
|
+
async function confirmElevated(action, slug, yes) {
|
|
31
|
+
await confirmTyped({ action: `${action} "${slug}"`, expected: slug, label: "the endpoint slug", yes });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generic typed confirmation: the operator must type `expected` back (or pass
|
|
35
|
+
* `--yes`). Refuses without a terminal so scripts cannot stumble into it.
|
|
36
|
+
*/
|
|
37
|
+
async function confirmTyped(input) {
|
|
38
|
+
if (input.yes)
|
|
39
|
+
return;
|
|
40
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
41
|
+
throw new Error(`refusing to ${input.action} without confirmation. Re-run with --yes to confirm.`);
|
|
42
|
+
}
|
|
43
|
+
if (input.preview)
|
|
44
|
+
process.stderr.write(`${input.preview}\n`);
|
|
45
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
46
|
+
try {
|
|
47
|
+
const typed = (await rl.question(`Type ${input.label} (${input.expected}) to ${input.action}: `)).trim();
|
|
48
|
+
if (typed !== input.expected)
|
|
49
|
+
throw new Error(`aborted: "${typed}" did not match "${input.expected}"`);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Prints the backend's activation notice (with the claim token merged in) for inactive endpoints. */
|
|
56
|
+
async function noteActivation(endpoints) {
|
|
57
|
+
const pending = endpoints.filter(isEndpointPending);
|
|
58
|
+
if (pending.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
const stored = await readStoredAuth();
|
|
61
|
+
for (const e of pending) {
|
|
62
|
+
process.stderr.write(`\n! ${e.slug} is not active yet.\n${formatActivation(e, stored?.claimURL)}\n\n`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** JSON view of an endpoint with the activation link resolved for this install. */
|
|
66
|
+
async function endpointJSON(e) {
|
|
67
|
+
const stored = await readStoredAuth();
|
|
68
|
+
const link = activationLink(e, stored?.claimURL);
|
|
69
|
+
return {
|
|
70
|
+
...e,
|
|
71
|
+
...(e.activation ? { activation: { ...e.activation, ...(link ? { url: link } : {}) } } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
17
74
|
export async function cmdEndpoints(options) {
|
|
18
75
|
if (options.setDefault && options.clearDefault)
|
|
19
76
|
throw new Error("use either --set-default or --clear-default");
|
|
@@ -24,7 +81,7 @@ export async function cmdEndpoints(options) {
|
|
|
24
81
|
}
|
|
25
82
|
if (options.setDefault) {
|
|
26
83
|
const wanted = options.setDefault.trim();
|
|
27
|
-
const match = catalog.endpoints
|
|
84
|
+
const match = findEndpoint(catalog.endpoints, wanted);
|
|
28
85
|
if (!match) {
|
|
29
86
|
throw new Error(`no endpoint named "${wanted}". Available: ${catalog.endpoints.map((e) => e.slug).join(", ") || "(none)"}`);
|
|
30
87
|
}
|
|
@@ -32,12 +89,13 @@ export async function cmdEndpoints(options) {
|
|
|
32
89
|
process.stderr.write(`Default endpoint set to ${match.slug}.\n`);
|
|
33
90
|
}
|
|
34
91
|
const defaultSlug = (await readStoredAuth())?.defaultEndpoint;
|
|
35
|
-
if (options
|
|
36
|
-
|
|
92
|
+
if (isJSON(options)) {
|
|
93
|
+
const endpoints = await Promise.all(catalog.endpoints.map(endpointJSON));
|
|
94
|
+
process.stdout.write(`${JSON.stringify({ ...catalog, endpoints, defaultEndpoint: defaultSlug ?? null }, null, 2)}\n`);
|
|
37
95
|
return;
|
|
38
96
|
}
|
|
39
97
|
if (catalog.endpoints.length === 0) {
|
|
40
|
-
process.stdout.write(
|
|
98
|
+
process.stdout.write(`No inference endpoints yet. Create one with \`heyditto endpoints create\`, or at ${endpointURL()}.\n`);
|
|
41
99
|
return;
|
|
42
100
|
}
|
|
43
101
|
const rows = catalog.endpoints.map((e) => [
|
|
@@ -45,7 +103,7 @@ export async function cmdEndpoints(options) {
|
|
|
45
103
|
e.slug,
|
|
46
104
|
e.model,
|
|
47
105
|
spendColumn(e),
|
|
48
|
-
e.recordTrace ? "traces" : "",
|
|
106
|
+
[isEndpointPending(e) ? "inactive" : "", e.recordTrace ? "traces" : ""].filter(Boolean).join(" "),
|
|
49
107
|
]);
|
|
50
108
|
const widths = [1, 0, 0, 0];
|
|
51
109
|
for (const r of rows)
|
|
@@ -56,6 +114,429 @@ export async function cmdEndpoints(options) {
|
|
|
56
114
|
process.stdout.write(`${r[0]} ${pad(r[1], widths[1])} ${pad(r[2], widths[2])} ${pad(r[3], widths[3])} ${r[4]}\n`);
|
|
57
115
|
}
|
|
58
116
|
process.stdout.write(`\nGateway: ${catalog.baseUrl}${defaultSlug ? ` (* = default)` : ""}\n`);
|
|
117
|
+
await noteActivation(catalog.endpoints);
|
|
118
|
+
}
|
|
119
|
+
export async function cmdEndpointCreate(options) {
|
|
120
|
+
const input = {};
|
|
121
|
+
if (options.name?.trim())
|
|
122
|
+
input.name = options.name.trim();
|
|
123
|
+
if (options.slug?.trim())
|
|
124
|
+
input.slug = options.slug.trim().toLowerCase();
|
|
125
|
+
if (options.model?.trim())
|
|
126
|
+
input.model = options.model.trim();
|
|
127
|
+
const created = await createEndpoint(input);
|
|
128
|
+
const stored = await readStoredAuth();
|
|
129
|
+
const makeDefault = options.default || !stored?.defaultEndpoint;
|
|
130
|
+
if (makeDefault)
|
|
131
|
+
await updateStoredAuth({ defaultEndpoint: created.slug });
|
|
132
|
+
if (isJSON(options)) {
|
|
133
|
+
process.stdout.write(`${JSON.stringify({ ...(await endpointJSON(created)), defaultEndpoint: makeDefault ? created.slug : (stored?.defaultEndpoint ?? null) }, null, 2)}\n`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
process.stdout.write(`Created endpoint ${created.slug} (model ${created.model})${makeDefault ? " — now the default" : ""}.\n`);
|
|
137
|
+
if (!isEndpointPending(created)) {
|
|
138
|
+
process.stdout.write(`Launch with: heyditto claude --endpoint ${created.slug}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await noteActivation([created]);
|
|
142
|
+
}
|
|
143
|
+
export async function cmdEndpointShow(ref, options) {
|
|
144
|
+
const { endpoint, catalog } = await getEndpoint(ref);
|
|
145
|
+
const defaultSlug = (await readStoredAuth())?.defaultEndpoint;
|
|
146
|
+
if (isJSON(options)) {
|
|
147
|
+
process.stdout.write(`${JSON.stringify({ ...(await endpointJSON(endpoint)), baseUrl: catalog.baseUrl, isDefault: endpoint.slug === defaultSlug }, null, 2)}\n`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const lines = [
|
|
151
|
+
`slug: ${endpoint.slug}${endpoint.slug === defaultSlug ? " (default)" : ""}`,
|
|
152
|
+
`name: ${endpoint.name}`,
|
|
153
|
+
`id: ${endpoint.id}`,
|
|
154
|
+
`model: ${endpoint.model}${endpoint.modelMode ? ` (${endpoint.modelMode})` : ""}`,
|
|
155
|
+
`status: ${endpoint.status ?? "active"}`,
|
|
156
|
+
`spend: ${spendColumn(endpoint)}`,
|
|
157
|
+
`memory: recall ${endpoint.recallEnabled === false ? "off" : "on"}, record ${endpoint.recordEnabled === false ? "off" : "on"}${endpoint.memoryDepth !== undefined ? `, depth ${endpoint.memoryDepth}` : ""}`,
|
|
158
|
+
`traces: ${endpoint.recordTrace ? "on" : "off"}`,
|
|
159
|
+
`tools: ${(endpoint.tools ?? []).join(", ") || "(none)"}`,
|
|
160
|
+
`gateway: ${catalog.baseUrl}`,
|
|
161
|
+
`web: ${endpointURL(endpoint.id)}`,
|
|
162
|
+
];
|
|
163
|
+
if (endpoint.systemPrompt)
|
|
164
|
+
lines.push(`system prompt: ${endpoint.systemPrompt.length > 120 ? `${endpoint.systemPrompt.slice(0, 117)}…` : endpoint.systemPrompt}`);
|
|
165
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
166
|
+
await noteActivation([endpoint]);
|
|
167
|
+
}
|
|
168
|
+
export async function cmdEndpointUse(ref, options) {
|
|
169
|
+
const { endpoint } = await getEndpoint(ref);
|
|
170
|
+
await updateStoredAuth({ defaultEndpoint: endpoint.slug });
|
|
171
|
+
if (isJSON(options)) {
|
|
172
|
+
process.stdout.write(`${JSON.stringify({ defaultEndpoint: endpoint.slug }, null, 2)}\n`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write(`Default endpoint set to ${endpoint.slug}.\n`);
|
|
176
|
+
await noteActivation([endpoint]);
|
|
177
|
+
}
|
|
178
|
+
export async function cmdEndpointPick(options) {
|
|
179
|
+
const catalog = await listEndpoints();
|
|
180
|
+
if (catalog.endpoints.length === 0) {
|
|
181
|
+
throw new Error("you have no inference endpoints yet. Create one with `heyditto endpoints create`.");
|
|
182
|
+
}
|
|
183
|
+
const stored = (await readStoredAuth())?.defaultEndpoint;
|
|
184
|
+
const picked = await pickEndpoint(catalog.endpoints, stored);
|
|
185
|
+
await updateStoredAuth({ defaultEndpoint: picked.slug });
|
|
186
|
+
if (isJSON(options)) {
|
|
187
|
+
process.stdout.write(`${JSON.stringify({ defaultEndpoint: picked.slug }, null, 2)}\n`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(`Default endpoint set to ${picked.slug}.\n`);
|
|
191
|
+
await noteActivation([picked]);
|
|
192
|
+
}
|
|
193
|
+
export async function cmdEndpointOpen(ref, options) {
|
|
194
|
+
// The developer console addresses endpoints by id; resolve the slug (or the
|
|
195
|
+
// stored default) through the catalog. No target → the endpoints list.
|
|
196
|
+
const target = ref ?? (await readStoredAuth())?.defaultEndpoint;
|
|
197
|
+
const id = target ? (await getEndpoint(target)).endpoint.id : undefined;
|
|
198
|
+
const url = endpointURL(id);
|
|
199
|
+
process.stdout.write(`${url}\n`);
|
|
200
|
+
if (!options.print) {
|
|
201
|
+
process.stderr.write("Opening in your browser…\n");
|
|
202
|
+
openInBrowser(url);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function onOff(flag, raw) {
|
|
206
|
+
if (raw === undefined)
|
|
207
|
+
return undefined;
|
|
208
|
+
const v = raw.trim().toLowerCase();
|
|
209
|
+
if (v === "on" || v === "true" || v === "yes")
|
|
210
|
+
return true;
|
|
211
|
+
if (v === "off" || v === "false" || v === "no")
|
|
212
|
+
return false;
|
|
213
|
+
throw new Error(`${flag} must be on or off`);
|
|
214
|
+
}
|
|
215
|
+
export async function cmdEndpointSet(ref, options) {
|
|
216
|
+
const patch = {};
|
|
217
|
+
if (options.name !== undefined)
|
|
218
|
+
patch.name = options.name.trim();
|
|
219
|
+
if (options.model !== undefined)
|
|
220
|
+
patch.model = options.model.trim();
|
|
221
|
+
if (options.systemPrompt !== undefined)
|
|
222
|
+
patch.systemPrompt = options.systemPrompt;
|
|
223
|
+
if (options.spendLimit !== undefined) {
|
|
224
|
+
const raw = options.spendLimit.trim().toLowerCase();
|
|
225
|
+
if (raw === "none" || raw === "unlimited" || raw === "off") {
|
|
226
|
+
patch.spendLimitTokens = null;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const n = Number(raw.replace(/[_,]/g, ""));
|
|
230
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
231
|
+
throw new Error(`--spend-limit must be a positive integer token count or "none", got "${options.spendLimit}"`);
|
|
232
|
+
patch.spendLimitTokens = n;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (options.spendPeriod !== undefined)
|
|
236
|
+
patch.spendPeriod = options.spendPeriod;
|
|
237
|
+
const recordTrace = onOff("--record-trace", options.recordTrace);
|
|
238
|
+
if (recordTrace !== undefined)
|
|
239
|
+
patch.recordTrace = recordTrace;
|
|
240
|
+
const recall = onOff("--recall", options.recall);
|
|
241
|
+
if (recall !== undefined)
|
|
242
|
+
patch.recallEnabled = recall;
|
|
243
|
+
const record = onOff("--record", options.record);
|
|
244
|
+
if (record !== undefined)
|
|
245
|
+
patch.recordEnabled = record;
|
|
246
|
+
if (options.memoryDepth !== undefined) {
|
|
247
|
+
const n = Number(options.memoryDepth);
|
|
248
|
+
if (!Number.isInteger(n) || n < 0 || n > 25)
|
|
249
|
+
throw new Error("--memory-depth must be an integer from 0 to 25");
|
|
250
|
+
patch.memoryDepth = n;
|
|
251
|
+
}
|
|
252
|
+
if (Object.keys(patch).length === 0)
|
|
253
|
+
throw new Error("nothing to change; pass at least one --flag (see `heyditto endpoints set --help`)");
|
|
254
|
+
const { endpoint } = await getEndpoint(ref);
|
|
255
|
+
// Raising or removing a spend cap lets the endpoint spend more credits.
|
|
256
|
+
const raisesSpend = patch.spendLimitTokens === null ||
|
|
257
|
+
(typeof patch.spendLimitTokens === "number" &&
|
|
258
|
+
endpoint.spendLimitTokens != null &&
|
|
259
|
+
endpoint.spendLimitTokens >= 0 &&
|
|
260
|
+
patch.spendLimitTokens > endpoint.spendLimitTokens) ||
|
|
261
|
+
(patch.spendPeriod !== undefined && patch.spendPeriod !== endpoint.spendPeriod && patch.spendPeriod === "never");
|
|
262
|
+
if (raisesSpend)
|
|
263
|
+
await confirmElevated("raise the spend limit of", endpoint.slug, options.yes);
|
|
264
|
+
const updated = await updateEndpoint(endpoint.id, patch);
|
|
265
|
+
if (updated.slug !== endpoint.slug && (await readStoredAuth())?.defaultEndpoint === endpoint.slug) {
|
|
266
|
+
await updateStoredAuth({ defaultEndpoint: updated.slug });
|
|
267
|
+
}
|
|
268
|
+
if (isJSON(options)) {
|
|
269
|
+
process.stdout.write(`${JSON.stringify(await endpointJSON(updated), null, 2)}\n`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
process.stdout.write(`Updated ${updated.slug}: ${Object.keys(patch).join(", ")}.\n`);
|
|
273
|
+
await noteActivation([updated]);
|
|
274
|
+
}
|
|
275
|
+
export async function cmdEndpointDelete(ref, options) {
|
|
276
|
+
const { endpoint } = await getEndpoint(ref);
|
|
277
|
+
await confirmElevated("delete", endpoint.slug, options.yes);
|
|
278
|
+
await deleteEndpoint(endpoint.id);
|
|
279
|
+
const stored = await readStoredAuth();
|
|
280
|
+
if (stored?.defaultEndpoint === endpoint.slug || stored?.defaultEndpoint === endpoint.id) {
|
|
281
|
+
await updateStoredAuth({ defaultEndpoint: undefined });
|
|
282
|
+
}
|
|
283
|
+
if (isJSON(options)) {
|
|
284
|
+
process.stdout.write(`${JSON.stringify({ deleted: endpoint.id, slug: endpoint.slug }, null, 2)}\n`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
process.stdout.write(`Deleted endpoint ${endpoint.slug}. Its keys stop working immediately; threads and traces are kept.\n`);
|
|
288
|
+
}
|
|
289
|
+
export async function cmdEndpointKeys(ref, options) {
|
|
290
|
+
const { endpoint } = await getEndpoint(ref);
|
|
291
|
+
const keys = await listKeys(endpoint.id);
|
|
292
|
+
if (isJSON(options)) {
|
|
293
|
+
process.stdout.write(`${JSON.stringify({ endpoint: { id: endpoint.id, slug: endpoint.slug }, keys }, null, 2)}\n`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (keys.length === 0) {
|
|
297
|
+
process.stdout.write(`No keys on ${endpoint.slug}. \`heyditto claude\` mints a temporary one per session.\n`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const rows = keys.map((k) => [
|
|
301
|
+
k.id,
|
|
302
|
+
`…${k.keyHint}`,
|
|
303
|
+
k.name,
|
|
304
|
+
k.revokedAt ? "revoked" : k.expiresAt ? `expires ${k.expiresAt.slice(0, 10)}` : "no expiry",
|
|
305
|
+
k.lastUsedAt ? `used ${k.lastUsedAt.slice(0, 16).replace("T", " ")}` : "",
|
|
306
|
+
]);
|
|
307
|
+
const header = ["ID", "KEY", "NAME", "STATE", "LAST USED"];
|
|
308
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
309
|
+
const line = (r) => r.map((c, i) => (i === r.length - 1 ? c : pad(c, widths[i]))).join(" ");
|
|
310
|
+
process.stdout.write(`${line(header)}\n`);
|
|
311
|
+
for (const r of rows)
|
|
312
|
+
process.stdout.write(`${line(r)}\n`);
|
|
313
|
+
}
|
|
314
|
+
export async function cmdEndpointKeysRevoke(ref, keyId, options) {
|
|
315
|
+
const { endpoint } = await getEndpoint(ref);
|
|
316
|
+
await confirmElevated(`revoke key ${keyId} on`, endpoint.slug, options.yes);
|
|
317
|
+
await revokeKey(endpoint.id, keyId);
|
|
318
|
+
if (isJSON(options)) {
|
|
319
|
+
process.stdout.write(`${JSON.stringify({ revoked: keyId, endpoint: endpoint.slug }, null, 2)}\n`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write(`Revoked key ${keyId} on ${endpoint.slug}.\n`);
|
|
323
|
+
}
|
|
324
|
+
function parseKeyBudget(raw) {
|
|
325
|
+
if (raw === undefined)
|
|
326
|
+
return undefined;
|
|
327
|
+
const n = Number(raw.replace(/[_,]/g, ""));
|
|
328
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
329
|
+
throw new Error(`--budget must be a positive integer token count, got "${raw}"`);
|
|
330
|
+
return n;
|
|
331
|
+
}
|
|
332
|
+
function parseKeyExpiry(raw) {
|
|
333
|
+
const value = (raw ?? "1y").trim();
|
|
334
|
+
if (KEY_EXPIRIES.includes(value))
|
|
335
|
+
return value;
|
|
336
|
+
throw new Error(`--expires must be one of: ${KEY_EXPIRIES.join(", ")}`);
|
|
337
|
+
}
|
|
338
|
+
/** Resolves where the secret goes from --repo / --env / --org (repo falls back to the cwd, like gh). */
|
|
339
|
+
function resolveSecretTarget(options) {
|
|
340
|
+
const org = options.org?.trim();
|
|
341
|
+
const env = options.env?.trim();
|
|
342
|
+
if (org) {
|
|
343
|
+
if (options.repo || env)
|
|
344
|
+
throw new Error("--org cannot be combined with --repo or --env (organization secrets are not scoped to one repository)");
|
|
345
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(org))
|
|
346
|
+
throw new Error(`--org must be an organization login, got "${options.org}"`);
|
|
347
|
+
return { kind: "org", org };
|
|
348
|
+
}
|
|
349
|
+
const repo = options.repo ? validateRepo(options.repo) : resolveRepoFromCwd();
|
|
350
|
+
if (env)
|
|
351
|
+
return { kind: "env", repo, env };
|
|
352
|
+
return { kind: "repo", repo };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Mints a key on an endpoint and hands the plaintext straight to `gh secret
|
|
356
|
+
* set` over stdin. The key is never printed, logged or stored locally; on a
|
|
357
|
+
* failed `gh` call it is revoked again so nothing usable is left behind.
|
|
358
|
+
*/
|
|
359
|
+
export async function cmdEndpointKeysCreate(ref, options) {
|
|
360
|
+
if (!options.ghSecret)
|
|
361
|
+
throw new Error("--gh-secret <NAME> is required: this command only mints keys straight into a GitHub Actions secret");
|
|
362
|
+
const secretName = validateSecretName(options.ghSecret);
|
|
363
|
+
const budget = parseKeyBudget(options.budget);
|
|
364
|
+
const expiresIn = parseKeyExpiry(options.expires);
|
|
365
|
+
if (options.spendPeriod !== undefined && budget === undefined)
|
|
366
|
+
throw new Error("--spend-period only applies together with --budget");
|
|
367
|
+
const spendPeriod = budget !== undefined ? (options.spendPeriod ?? "monthly") : undefined;
|
|
368
|
+
// Everything that can fail cheaply happens before any write: gh present and
|
|
369
|
+
// signed in, target repo known, endpoint exists, operator confirmed.
|
|
370
|
+
preflightGh();
|
|
371
|
+
const target = resolveSecretTarget(options);
|
|
372
|
+
const { endpoint, catalog } = await getEndpoint(ref);
|
|
373
|
+
const keyName = options.name?.trim() || (target.kind === "org" ? `gh-secret:${secretName}` : `gh:${target.repo}:${secretName}`);
|
|
374
|
+
const plan = `Will mint key "${keyName}" on ${endpoint.slug} (expires ${expiresIn}${budget !== undefined ? `, budget ${budget.toLocaleString()} tokens ${spendPeriod}` : ""}) and set secret ${secretName} on ${describeTarget(target)}.`;
|
|
375
|
+
await confirmTyped({ action: `mint a key on ${endpoint.slug} and set secret ${secretName}`, expected: secretName, label: "the secret name", yes: options.yes, preview: plan });
|
|
376
|
+
const minted = await createKey(endpoint.id, {
|
|
377
|
+
name: keyName,
|
|
378
|
+
expiresIn,
|
|
379
|
+
...(budget !== undefined ? { spendLimitTokens: budget, spendPeriod } : {}),
|
|
380
|
+
});
|
|
381
|
+
// Split the plaintext off immediately; only `plaintext` may reach gh's stdin.
|
|
382
|
+
const { key: plaintext, ...key } = minted;
|
|
383
|
+
try {
|
|
384
|
+
setGitHubSecret(secretName, target, plaintext ?? "");
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
388
|
+
try {
|
|
389
|
+
await revokeKey(endpoint.id, key.id);
|
|
390
|
+
}
|
|
391
|
+
catch (revokeErr) {
|
|
392
|
+
const detail = revokeErr instanceof Error ? revokeErr.message : String(revokeErr);
|
|
393
|
+
throw new Error(`${reason}\nMinted key ${key.id} (…${key.keyHint}) on ${endpoint.slug} could NOT be revoked (${detail}). Revoke it now: heyditto endpoints keys revoke ${endpoint.slug} ${key.id} --yes, or at ${endpointURL(endpoint.id)}`);
|
|
394
|
+
}
|
|
395
|
+
throw new Error(`${reason}\nThe key minted for it (…${key.keyHint}) was revoked again; nothing was stored.`);
|
|
396
|
+
}
|
|
397
|
+
const anthropicBaseUrl = apiRootOf(catalog.baseUrl);
|
|
398
|
+
const openaiBaseUrl = catalog.baseUrl;
|
|
399
|
+
const snippet = `\${{ secrets.${secretName} }}`;
|
|
400
|
+
if (isJSON(options)) {
|
|
401
|
+
process.stdout.write(`${JSON.stringify({
|
|
402
|
+
endpoint: { id: endpoint.id, slug: endpoint.slug },
|
|
403
|
+
key: {
|
|
404
|
+
id: key.id,
|
|
405
|
+
name: key.name ?? keyName,
|
|
406
|
+
keyHint: key.keyHint,
|
|
407
|
+
expiresIn,
|
|
408
|
+
expiresAt: key.expiresAt ?? null,
|
|
409
|
+
spendLimitTokens: key.spendLimitTokens ?? budget ?? null,
|
|
410
|
+
spendPeriod: key.spendPeriod ?? spendPeriod ?? null,
|
|
411
|
+
},
|
|
412
|
+
secret: { name: secretName, ...target, snippet },
|
|
413
|
+
gateway: { baseUrl: catalog.baseUrl, anthropicBaseUrl, openaiBaseUrl },
|
|
414
|
+
}, null, 2)}\n`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
process.stdout.write([
|
|
418
|
+
`Minted key …${key.keyHint} (${key.name ?? keyName}) on ${endpoint.slug}: expires ${expiresIn}${budget !== undefined ? `, budget ${budget.toLocaleString()} tokens ${spendPeriod}` : ", no spend cap"}.`,
|
|
419
|
+
`Stored it as GitHub Actions secret ${secretName} on ${describeTarget(target)}. The key was not printed and is not kept locally.`,
|
|
420
|
+
"",
|
|
421
|
+
"Use it in a workflow step:",
|
|
422
|
+
" env:",
|
|
423
|
+
` ANTHROPIC_AUTH_TOKEN: ${snippet}`,
|
|
424
|
+
` ANTHROPIC_BASE_URL: ${anthropicBaseUrl}`,
|
|
425
|
+
` # OpenAI-compatible clients: OPENAI_API_KEY: ${snippet} with OPENAI_BASE_URL: ${openaiBaseUrl}`,
|
|
426
|
+
"",
|
|
427
|
+
`Revoke later with: heyditto endpoints keys revoke ${endpoint.slug} ${key.id}`,
|
|
428
|
+
].join("\n") + "\n");
|
|
429
|
+
}
|
|
430
|
+
/** Registers the `endpoints` group; bare `heyditto endpoints [flags]` still lists. */
|
|
431
|
+
export function registerEndpointCommands(program, addExamples, outputOption) {
|
|
432
|
+
const endpoints = program
|
|
433
|
+
.command("endpoints")
|
|
434
|
+
.description("manage the inference endpoints used by heyditto claude / codex")
|
|
435
|
+
.summary("manage inference endpoints")
|
|
436
|
+
.showHelpAfterError()
|
|
437
|
+
.addHelpText("after", `
|
|
438
|
+
Endpoint controls spend your Ditto credits, so delete, key revocation and
|
|
439
|
+
spend-limit increases ask you to type the slug back (or pass --yes).
|
|
440
|
+
'keys create --gh-secret' mints a key straight into a GitHub Actions secret
|
|
441
|
+
through the gh CLI; the plaintext never reaches your terminal.`);
|
|
442
|
+
addExamples(endpoints
|
|
443
|
+
.command("list", { isDefault: true })
|
|
444
|
+
.description("list your inference endpoints (* = default)")
|
|
445
|
+
.option("--set-default <slug>", "endpoint to use when --endpoint is omitted")
|
|
446
|
+
.option("--clear-default", "forget the default endpoint")
|
|
447
|
+
.addOption(outputOption())
|
|
448
|
+
.action(cmdEndpoints), ` heyditto endpoints
|
|
449
|
+
heyditto endpoints --set-default my-endpoint
|
|
450
|
+
heyditto endpoints list --output json`);
|
|
451
|
+
addExamples(endpoints
|
|
452
|
+
.command("create")
|
|
453
|
+
.description("create an endpoint (one click: name, slug and model are generated when omitted)")
|
|
454
|
+
.option("--name <name>", "display name")
|
|
455
|
+
.option("--slug <slug>", "url-safe slug (lowercase letters, digits, dashes)")
|
|
456
|
+
.option("--model <id>", "default model id (default: the gateway's default model)")
|
|
457
|
+
.option("--default", "make it the default for heyditto claude / codex (automatic when you have no default yet)")
|
|
458
|
+
.addOption(outputOption())
|
|
459
|
+
.action(cmdEndpointCreate), ` heyditto endpoints create
|
|
460
|
+
heyditto endpoints create --name "Work laptop" --model anthropic/claude-sonnet-5 --default`);
|
|
461
|
+
endpoints
|
|
462
|
+
.command("show")
|
|
463
|
+
.description("show one endpoint's settings")
|
|
464
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
465
|
+
.addOption(outputOption())
|
|
466
|
+
.action(cmdEndpointShow);
|
|
467
|
+
endpoints
|
|
468
|
+
.command("use")
|
|
469
|
+
.description("make an endpoint the default for heyditto claude / codex")
|
|
470
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
471
|
+
.addOption(outputOption())
|
|
472
|
+
.action(cmdEndpointUse);
|
|
473
|
+
endpoints
|
|
474
|
+
.command("pick")
|
|
475
|
+
.description("choose the default endpoint interactively")
|
|
476
|
+
.addOption(outputOption())
|
|
477
|
+
.action(cmdEndpointPick);
|
|
478
|
+
endpoints
|
|
479
|
+
.command("open")
|
|
480
|
+
.description("open the endpoint editor in the Ditto app (default endpoint when omitted)")
|
|
481
|
+
.argument("[endpoint]", "endpoint slug or id")
|
|
482
|
+
.option("--print", "print the URL without opening a browser")
|
|
483
|
+
.action(cmdEndpointOpen);
|
|
484
|
+
addExamples(endpoints
|
|
485
|
+
.command("set")
|
|
486
|
+
.description("change an endpoint's settings (mirror of the web editor)")
|
|
487
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
488
|
+
.option("--name <name>", "display name")
|
|
489
|
+
.option("--model <id>", "default model id")
|
|
490
|
+
.option("--system-prompt <text>", "system prompt prepended to every request")
|
|
491
|
+
.option("--spend-limit <tokens|none>", "spend cap in Ditto tokens, or none")
|
|
492
|
+
.addOption(new Option("--spend-period <period>", "window the spend cap resets on").choices(["daily", "weekly", "monthly", "yearly", "never"]))
|
|
493
|
+
.option("--record-trace <on|off>", "store raw request/response traces")
|
|
494
|
+
.option("--recall <on|off>", "recall memories into requests")
|
|
495
|
+
.option("--record <on|off>", "record new memories from requests")
|
|
496
|
+
.option("--memory-depth <n>", "memories recalled per request (0-25)")
|
|
497
|
+
.option("--yes", "skip the confirmation when raising a spend limit")
|
|
498
|
+
.addOption(outputOption())
|
|
499
|
+
.action(cmdEndpointSet), ` heyditto endpoints set my-endpoint --model openai/gpt-5.6-luna --record-trace on
|
|
500
|
+
heyditto endpoints set my-endpoint --spend-limit 5000000 --spend-period monthly`);
|
|
501
|
+
endpoints
|
|
502
|
+
.command("delete")
|
|
503
|
+
.description("delete an endpoint (keys stop working; threads and traces are kept)")
|
|
504
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
505
|
+
.option("--yes", "skip the confirmation prompt")
|
|
506
|
+
.addOption(outputOption())
|
|
507
|
+
.action(cmdEndpointDelete);
|
|
508
|
+
const keys = endpoints
|
|
509
|
+
.command("keys")
|
|
510
|
+
.description("list an endpoint's API keys")
|
|
511
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
512
|
+
.addOption(outputOption())
|
|
513
|
+
.action(cmdEndpointKeys);
|
|
514
|
+
addExamples(keys
|
|
515
|
+
.command("create")
|
|
516
|
+
.description("mint a key and store it straight into a GitHub Actions secret via gh (the key is never printed)")
|
|
517
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
518
|
+
.requiredOption("--gh-secret <NAME>", "Actions secret name to set with the gh CLI")
|
|
519
|
+
.option("--repo <owner/repo>", "repository for the secret (default: the repo of the current directory, as gh resolves it)")
|
|
520
|
+
.option("--env <environment>", "set a deployment-environment secret on the repo instead of a repository secret")
|
|
521
|
+
.option("--org <org>", "set an organization secret instead (cannot be combined with --repo/--env)")
|
|
522
|
+
.option("--name <label>", "key name shown in the Ditto app (default: gh:<owner>/<repo>:<NAME>)")
|
|
523
|
+
.addOption(new Option("--expires <duration>", "server-side key expiry").choices([...KEY_EXPIRIES]).default("1y"))
|
|
524
|
+
.option("--budget <tokens>", "spend cap for the key, in Ditto tokens")
|
|
525
|
+
.addOption(new Option("--spend-period <period>", "window the key's spend cap resets on (with --budget; default monthly)").choices(["daily", "weekly", "monthly", "yearly", "never"]))
|
|
526
|
+
.option("--yes", "skip the confirmation prompt (required without a terminal)")
|
|
527
|
+
.addOption(outputOption())
|
|
528
|
+
.action(cmdEndpointKeysCreate), ` heyditto endpoints keys create my-endpoint --gh-secret DITTO_KEY # repo of the current directory
|
|
529
|
+
heyditto endpoints keys create my-endpoint --gh-secret DITTO_KEY --repo acme/app --budget 5000000
|
|
530
|
+
heyditto endpoints keys create my-endpoint --gh-secret DITTO_KEY --repo acme/app --env production --yes
|
|
531
|
+
heyditto endpoints keys create my-endpoint --gh-secret DITTO_KEY --org acme --expires 6mo --output json`);
|
|
532
|
+
keys
|
|
533
|
+
.command("revoke")
|
|
534
|
+
.description("revoke one key")
|
|
535
|
+
.argument("<endpoint>", "endpoint slug or id")
|
|
536
|
+
.argument("<keyId>", "key id (see `heyditto endpoints keys <endpoint>`)")
|
|
537
|
+
.option("--yes", "skip the confirmation prompt")
|
|
538
|
+
.addOption(outputOption())
|
|
539
|
+
.action(cmdEndpointKeysRevoke);
|
|
59
540
|
}
|
|
60
541
|
export async function cmdSessions(options) {
|
|
61
542
|
const records = await listSessions();
|
|
@@ -115,7 +596,7 @@ export function registerHarnessCommands(program, addExamples) {
|
|
|
115
596
|
cmd.action(async (args, options) => {
|
|
116
597
|
await launchHarness(harness, args, options);
|
|
117
598
|
});
|
|
118
|
-
addExamples(cmd, ` heyditto ${harness} pick an endpoint
|
|
599
|
+
addExamples(cmd, ` heyditto ${harness} first run: sign in + pick an endpoint in the browser, then launch
|
|
119
600
|
heyditto ${harness} --endpoint my-endpoint --budget 500000
|
|
120
601
|
heyditto ${harness} --yellow --worktree feature-x
|
|
121
602
|
heyditto ${harness} -p "summarize this repo" --output-format json
|