@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a
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/bun.lock +8 -0
- package/knip.json +5 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +511 -11
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +16 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +15 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +20 -6
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum connect import <blob> [--name <localname>]`
|
|
3
|
+
*
|
|
4
|
+
* Import a pairing bundle printed by `vellum pair` on another machine and
|
|
5
|
+
* register it locally so `vellum client`/`message`/`events <name>` work against
|
|
6
|
+
* the remote assistant.
|
|
7
|
+
*
|
|
8
|
+
* The bundle is base64(JSON.stringify({ gatewayUrl, assistantId, token,
|
|
9
|
+
* deviceId })). We store the entry under a UNIQUE LOCAL id (not the bundle's
|
|
10
|
+
* assistantId, which is typically "self" and would collide across hosts). This
|
|
11
|
+
* is safe because the gateway's runtime proxy strips the `/v1/assistants/<id>/`
|
|
12
|
+
* segment before forwarding, so the local id never has to match the remote one
|
|
13
|
+
* — the token (validated by signature/audience) is what authorizes requests.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { nanoid } from "nanoid";
|
|
17
|
+
|
|
18
|
+
import { extractFlag } from "../../lib/arg-utils.js";
|
|
19
|
+
import {
|
|
20
|
+
findAssistantByName,
|
|
21
|
+
saveAssistantEntry,
|
|
22
|
+
} from "../../lib/assistant-config.js";
|
|
23
|
+
import { saveGuardianToken } from "../../lib/guardian-token.js";
|
|
24
|
+
|
|
25
|
+
function printUsage(): void {
|
|
26
|
+
console.log(`vellum connect import - Register an assistant paired from another machine
|
|
27
|
+
|
|
28
|
+
USAGE:
|
|
29
|
+
vellum connect import <bundle> [options]
|
|
30
|
+
|
|
31
|
+
ARGUMENTS:
|
|
32
|
+
<bundle> The base64 bundle printed by 'vellum pair' on the host machine
|
|
33
|
+
|
|
34
|
+
OPTIONS:
|
|
35
|
+
--name <name> Local name to register the assistant under
|
|
36
|
+
(default: paired-<deviceId>)
|
|
37
|
+
|
|
38
|
+
EXAMPLES:
|
|
39
|
+
vellum connect import eyJnYXRld2F5...
|
|
40
|
+
vellum connect import eyJnYXRld2F5... --name desk
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PairBundle {
|
|
45
|
+
gatewayUrl: string;
|
|
46
|
+
token: string;
|
|
47
|
+
assistantId?: string;
|
|
48
|
+
deviceId?: string;
|
|
49
|
+
// Optional refresh credential. Present when the host's gateway issued a
|
|
50
|
+
// device-bound token pair; absent for older access-only bundles (which remain
|
|
51
|
+
// importable, just without auto-renewal). `refreshTokenExpiresAt` mirrors
|
|
52
|
+
// GuardianTokenData (ISO string OR epoch-ms number) so a numeric expiry isn't
|
|
53
|
+
// silently dropped on import.
|
|
54
|
+
refreshToken?: string;
|
|
55
|
+
refreshTokenExpiresAt?: string | number;
|
|
56
|
+
refreshAfter?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Decode the base64 bundle, returning null if malformed or missing fields. */
|
|
60
|
+
function decodeBundle(blob: string): PairBundle | null {
|
|
61
|
+
let json: unknown;
|
|
62
|
+
try {
|
|
63
|
+
json = JSON.parse(Buffer.from(blob, "base64").toString("utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (typeof json !== "object" || json === null) return null;
|
|
68
|
+
const b = json as Record<string, unknown>;
|
|
69
|
+
if (typeof b.gatewayUrl !== "string" || typeof b.token !== "string") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// The gatewayUrl is persisted as runtimeUrl and used to build fetch URLs, so
|
|
73
|
+
// require an absolute http(s) URL here rather than letting an invalid string
|
|
74
|
+
// through (which would crash `new URL(...)` or break later client calls).
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(b.gatewayUrl);
|
|
77
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
gatewayUrl: b.gatewayUrl,
|
|
85
|
+
token: b.token,
|
|
86
|
+
assistantId: typeof b.assistantId === "string" ? b.assistantId : undefined,
|
|
87
|
+
deviceId: typeof b.deviceId === "string" ? b.deviceId : undefined,
|
|
88
|
+
refreshToken:
|
|
89
|
+
typeof b.refreshToken === "string" ? b.refreshToken : undefined,
|
|
90
|
+
refreshTokenExpiresAt:
|
|
91
|
+
typeof b.refreshTokenExpiresAt === "string" ||
|
|
92
|
+
typeof b.refreshTokenExpiresAt === "number"
|
|
93
|
+
? b.refreshTokenExpiresAt
|
|
94
|
+
: undefined,
|
|
95
|
+
refreshAfter:
|
|
96
|
+
typeof b.refreshAfter === "string" ? b.refreshAfter : undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Lowercase, collapse non-alphanumerics to single dashes, trim dashes. */
|
|
101
|
+
function slugify(name: string): string {
|
|
102
|
+
return name
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
105
|
+
.replace(/^-+|-+$/g, "");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Best-effort JWT `exp` (epoch seconds) → epoch ms; null if undecodable. */
|
|
109
|
+
function jwtExpiryMs(token: string): number | null {
|
|
110
|
+
const parts = token.split(".");
|
|
111
|
+
if (parts.length !== 3) return null;
|
|
112
|
+
try {
|
|
113
|
+
const payload = JSON.parse(
|
|
114
|
+
Buffer.from(parts[1], "base64").toString("utf8"),
|
|
115
|
+
);
|
|
116
|
+
if (typeof payload.exp === "number") return payload.exp * 1000;
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through */
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function connectImport(): Promise<void> {
|
|
124
|
+
const rawArgs = process.argv.slice(4);
|
|
125
|
+
|
|
126
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
127
|
+
printUsage();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const [nameFlag, args] = extractFlag(rawArgs, "--name");
|
|
132
|
+
const blob = args[0];
|
|
133
|
+
if (!blob) {
|
|
134
|
+
console.error("Error: missing pairing bundle.");
|
|
135
|
+
printUsage();
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bundle = decodeBundle(blob);
|
|
140
|
+
if (!bundle) {
|
|
141
|
+
console.error(
|
|
142
|
+
"Error: invalid pairing bundle. Paste the full base64 string printed by `vellum pair`.",
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Unique local id: a --name slug, or paired-<deviceId> (deviceId is unique
|
|
148
|
+
// per pairing). Never the bundle's "self" assistantId — that would collide.
|
|
149
|
+
// The deviceId comes from an untrusted bundle and is used as a path component
|
|
150
|
+
// by saveGuardianToken, so it MUST be slugified (no `../` traversal); fall
|
|
151
|
+
// back to a random id if it sanitizes to empty.
|
|
152
|
+
const localId = nameFlag
|
|
153
|
+
? slugify(nameFlag)
|
|
154
|
+
: `paired-${slugify(bundle.deviceId ?? "") || nanoid()}`;
|
|
155
|
+
if (!localId) {
|
|
156
|
+
console.error(
|
|
157
|
+
"Error: --name must contain at least one alphanumeric character.",
|
|
158
|
+
);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Don't clobber an existing assistant. Only update in place when the prior
|
|
163
|
+
// entry is itself a paired import (marked `paired: true`); otherwise the id
|
|
164
|
+
// collides with a real local/remote assistant and overwriting would drop its
|
|
165
|
+
// resources/runtime metadata. Reject and let the user pick a fresh --name.
|
|
166
|
+
const existing = findAssistantByName(localId);
|
|
167
|
+
if (existing && existing.paired !== true) {
|
|
168
|
+
console.error(
|
|
169
|
+
`Error: an assistant named '${localId}' already exists locally. ` +
|
|
170
|
+
"Choose a different --name to avoid overwriting it.",
|
|
171
|
+
);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const existed = existing !== null;
|
|
175
|
+
|
|
176
|
+
saveAssistantEntry({
|
|
177
|
+
assistantId: localId,
|
|
178
|
+
name: nameFlag ?? `paired (${new URL(bundle.gatewayUrl).host})`,
|
|
179
|
+
runtimeUrl: bundle.gatewayUrl,
|
|
180
|
+
// Paired entries are reached by bearer token at the remote runtimeUrl
|
|
181
|
+
// (a non-"vellum" cloud selects the bearer-token auth path in client.ts).
|
|
182
|
+
// The "paired" topology lets lifecycle/status commands (ps/wake/sleep)
|
|
183
|
+
// recognize this as a remote pairing rather than an on-machine process.
|
|
184
|
+
cloud: "paired",
|
|
185
|
+
// Marks this entry as a connect-import so re-imports update in place while
|
|
186
|
+
// imports never silently overwrite a non-paired assistant (see guard above).
|
|
187
|
+
paired: true,
|
|
188
|
+
species: "vellum",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const hasRefresh = Boolean(bundle.refreshToken);
|
|
193
|
+
saveGuardianToken(localId, {
|
|
194
|
+
guardianPrincipalId: "imported",
|
|
195
|
+
accessToken: bundle.token,
|
|
196
|
+
accessTokenExpiresAt:
|
|
197
|
+
jwtExpiryMs(bundle.token) ?? now + 24 * 60 * 60 * 1000,
|
|
198
|
+
refreshToken: bundle.refreshToken ?? "",
|
|
199
|
+
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt ?? 0,
|
|
200
|
+
refreshAfter: bundle.refreshAfter ?? "",
|
|
201
|
+
isNew: false,
|
|
202
|
+
deviceId: bundle.deviceId ?? "",
|
|
203
|
+
leasedAt: new Date(now).toISOString(),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log(
|
|
207
|
+
`${existed ? "Updated" : "Imported"} paired assistant '${localId}'.`,
|
|
208
|
+
);
|
|
209
|
+
console.log("");
|
|
210
|
+
console.log(` Connect with: vellum client ${localId}`);
|
|
211
|
+
console.log("");
|
|
212
|
+
console.log(
|
|
213
|
+
hasRefresh
|
|
214
|
+
? "Note: this connection includes a refresh credential, so it can renew itself — re-pair only if it's revoked or the refresh credential expires."
|
|
215
|
+
: "Note: the token is access-only and will expire — re-run `vellum pair` and import again when it does.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { connectImport } from "./connect/import.js";
|
|
2
|
+
|
|
3
|
+
function printUsage(): void {
|
|
4
|
+
console.log("Usage: vellum connect <subcommand>");
|
|
5
|
+
console.log("");
|
|
6
|
+
console.log("Connect to an assistant paired from another machine.");
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log("Subcommands:");
|
|
9
|
+
console.log(
|
|
10
|
+
" import Import a pairing bundle from `vellum pair` and register it",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function connect(): Promise<void> {
|
|
15
|
+
const args = process.argv.slice(3);
|
|
16
|
+
const subcommand = args[0];
|
|
17
|
+
|
|
18
|
+
if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
|
|
19
|
+
printUsage();
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (subcommand === "import") {
|
|
24
|
+
await connectImport();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
29
|
+
printUsage();
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum devices [name]` and `vellum devices revoke <hashedDeviceId> [name] [--yes]`
|
|
3
|
+
*
|
|
4
|
+
* Host-side of pairing lifecycle: list and revoke the devices paired to a LOCAL
|
|
5
|
+
* self-hosted assistant. Calls the loopback-only gateway endpoints
|
|
6
|
+
* `GET /v1/devices` and `POST /v1/devices/revoke` (added in the gateway slice),
|
|
7
|
+
* which self-guard loopback + reject any browser/WebView Origin. The gateway
|
|
8
|
+
* only ever stores the HASHED device id, so list returns and revoke accepts the
|
|
9
|
+
* same `hashedDeviceId` (the raw device id is never persisted anywhere).
|
|
10
|
+
*
|
|
11
|
+
* This is the counterpart to `vellum unpair`: `unpair` forgets a connection on
|
|
12
|
+
* the *paired* machine (client side); `devices` revokes a device from the *host*
|
|
13
|
+
* that runs the assistant (server side).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type AssistantEntry,
|
|
18
|
+
formatAssistantReference,
|
|
19
|
+
getAssistantDisplayName,
|
|
20
|
+
resolveTargetAssistant,
|
|
21
|
+
} from "../lib/assistant-config";
|
|
22
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
23
|
+
import {
|
|
24
|
+
CLI_INTERFACE_ID,
|
|
25
|
+
getClientRegistrationHeaders,
|
|
26
|
+
} from "../lib/client-identity.js";
|
|
27
|
+
import {
|
|
28
|
+
canPromptForConfirmation,
|
|
29
|
+
confirmAction,
|
|
30
|
+
} from "../lib/confirm-action.js";
|
|
31
|
+
|
|
32
|
+
interface DeviceRecord {
|
|
33
|
+
hashedDeviceId: string;
|
|
34
|
+
platform: string;
|
|
35
|
+
issuedAt: number | null;
|
|
36
|
+
expiresAt: number | null;
|
|
37
|
+
lastUsedAt: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printUsage(): void {
|
|
41
|
+
console.log(`vellum devices - List and revoke devices paired to a local assistant
|
|
42
|
+
|
|
43
|
+
USAGE:
|
|
44
|
+
vellum devices [name]
|
|
45
|
+
vellum devices revoke <hashedDeviceId> [name] [--yes]
|
|
46
|
+
|
|
47
|
+
ARGUMENTS:
|
|
48
|
+
[name] Name or id of the local assistant (defaults to the active/sole one)
|
|
49
|
+
<hashedDeviceId> The device's hashed id (copy it from the list output)
|
|
50
|
+
|
|
51
|
+
OPTIONS:
|
|
52
|
+
--yes Skip the interactive confirmation prompt when revoking (for automation)
|
|
53
|
+
|
|
54
|
+
Lists the devices paired to a local (host-side) assistant, or revokes one by its
|
|
55
|
+
hashed id. Runs on the machine that hosts the assistant — paired connections
|
|
56
|
+
imported from another machine are managed with 'vellum unpair' instead.
|
|
57
|
+
|
|
58
|
+
EXAMPLES:
|
|
59
|
+
vellum devices
|
|
60
|
+
vellum devices my-desk
|
|
61
|
+
vellum devices revoke 3f9a1c...
|
|
62
|
+
vellum devices revoke 3f9a1c... my-desk --yes
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the LOOPBACK gateway base URL for a host-side assistant, or exit with
|
|
68
|
+
* a helpful error. Refuses paired connections (they have no local gateway here)
|
|
69
|
+
* and never falls back to a non-loopback URL.
|
|
70
|
+
*/
|
|
71
|
+
function resolveLoopbackBase(entry: AssistantEntry): string {
|
|
72
|
+
const displayName = getAssistantDisplayName(entry);
|
|
73
|
+
|
|
74
|
+
if (entry.cloud === "paired") {
|
|
75
|
+
console.error(
|
|
76
|
+
`Error: '${displayName}' is a paired connection imported from another machine.`,
|
|
77
|
+
);
|
|
78
|
+
console.error(
|
|
79
|
+
"Run `vellum devices` on the host that runs the assistant to manage its devices.",
|
|
80
|
+
);
|
|
81
|
+
console.error("To forget this connection here, use `vellum unpair`.");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const base =
|
|
86
|
+
entry.localUrl ||
|
|
87
|
+
(entry.resources?.gatewayPort
|
|
88
|
+
? `http://127.0.0.1:${entry.resources.gatewayPort}`
|
|
89
|
+
: undefined);
|
|
90
|
+
if (!base) {
|
|
91
|
+
console.error(
|
|
92
|
+
`Error: no local gateway found for '${displayName}'. \`vellum devices\` runs on the machine hosting the assistant.`,
|
|
93
|
+
);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return base.replace(/\/+$/, "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Format an epoch-ms timestamp as ISO, or a placeholder when absent. */
|
|
101
|
+
function formatTimestamp(ms: number | null, absent: string): string {
|
|
102
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return absent;
|
|
103
|
+
return new Date(ms).toISOString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
|
|
107
|
+
const displayName = getAssistantDisplayName(entry);
|
|
108
|
+
|
|
109
|
+
let response: Response;
|
|
110
|
+
try {
|
|
111
|
+
response = await fetch(`${base}/v1/devices`, {
|
|
112
|
+
method: "GET",
|
|
113
|
+
headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(
|
|
117
|
+
`Error: could not reach the gateway for '${displayName}' at ${base}: ${
|
|
118
|
+
(err as Error).message
|
|
119
|
+
}`,
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
console.error(
|
|
126
|
+
`Error: gateway returned ${response.status} listing devices for '${displayName}'.`,
|
|
127
|
+
);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = (await response.json()) as { devices?: DeviceRecord[] };
|
|
132
|
+
const devices = body.devices ?? [];
|
|
133
|
+
|
|
134
|
+
if (devices.length === 0) {
|
|
135
|
+
console.log(`No devices are paired to ${displayName}.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`Devices paired to ${formatAssistantReference(entry)}:`);
|
|
140
|
+
console.log("");
|
|
141
|
+
for (const device of devices) {
|
|
142
|
+
console.log(` ${device.hashedDeviceId}`);
|
|
143
|
+
console.log(` platform: ${device.platform}`);
|
|
144
|
+
console.log(` issued: ${formatTimestamp(device.issuedAt, "—")}`);
|
|
145
|
+
console.log(` expires: ${formatTimestamp(device.expiresAt, "—")}`);
|
|
146
|
+
console.log(
|
|
147
|
+
` last used: ${formatTimestamp(device.lastUsedAt, "never")}`,
|
|
148
|
+
);
|
|
149
|
+
console.log("");
|
|
150
|
+
}
|
|
151
|
+
console.log(
|
|
152
|
+
`${devices.length} device(s). Revoke one with: vellum devices revoke <hashedDeviceId>`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function revokeDevice(
|
|
157
|
+
entry: AssistantEntry,
|
|
158
|
+
base: string,
|
|
159
|
+
hashedDeviceId: string,
|
|
160
|
+
yes: boolean,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const displayName = getAssistantDisplayName(entry);
|
|
163
|
+
|
|
164
|
+
// Print the resolved identity before acting (cli/AGENTS.md).
|
|
165
|
+
console.log("Device to revoke:");
|
|
166
|
+
console.log(` Assistant: ${formatAssistantReference(entry)}`);
|
|
167
|
+
console.log(` Device: ${hashedDeviceId}`);
|
|
168
|
+
console.log("");
|
|
169
|
+
|
|
170
|
+
if (!yes) {
|
|
171
|
+
if (!canPromptForConfirmation()) {
|
|
172
|
+
console.error(
|
|
173
|
+
"Error: Refusing to revoke without confirmation in a non-interactive terminal.",
|
|
174
|
+
);
|
|
175
|
+
console.error("Re-run with --yes to confirm from automation.");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const confirmed = await confirmAction(
|
|
179
|
+
"Press Enter to revoke, or Esc/q to cancel: ",
|
|
180
|
+
);
|
|
181
|
+
if (!confirmed) {
|
|
182
|
+
console.log("Revoke cancelled.");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let response: Response;
|
|
188
|
+
try {
|
|
189
|
+
response = await fetch(`${base}/v1/devices/revoke`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
...getClientRegistrationHeaders(CLI_INTERFACE_ID),
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({ hashedDeviceId }),
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error(
|
|
199
|
+
`Error: could not reach the gateway for '${displayName}' at ${base}: ${
|
|
200
|
+
(err as Error).message
|
|
201
|
+
}`,
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
console.error(
|
|
208
|
+
`Error: gateway returned ${response.status} revoking device for '${displayName}'.`,
|
|
209
|
+
);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
console.log(
|
|
214
|
+
`Revoked device ${hashedDeviceId} from ${displayName}. Its tokens are invalidated; that machine must re-pair to reconnect.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function devices(): Promise<void> {
|
|
219
|
+
const args = process.argv.slice(3);
|
|
220
|
+
|
|
221
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
222
|
+
printUsage();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (args[0] === "revoke") {
|
|
227
|
+
const rest = args.slice(1);
|
|
228
|
+
const yes = rest.includes("--yes");
|
|
229
|
+
const positionals = rest.filter((a) => !a.startsWith("-"));
|
|
230
|
+
const hashedDeviceId = positionals[0];
|
|
231
|
+
if (!hashedDeviceId) {
|
|
232
|
+
console.error("Error: a hashedDeviceId is required to revoke.");
|
|
233
|
+
printUsage();
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
const nameArg = positionals.slice(1).join(" ") || undefined;
|
|
237
|
+
const entry = resolveTargetAssistant(nameArg);
|
|
238
|
+
const base = resolveLoopbackBase(entry);
|
|
239
|
+
await revokeDevice(entry, base, hashedDeviceId, yes);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const nameArg = parseAssistantTargetArg(args, []);
|
|
244
|
+
const entry = resolveTargetAssistant(nameArg);
|
|
245
|
+
const base = resolveLoopbackBase(entry);
|
|
246
|
+
await listDevices(entry, base);
|
|
247
|
+
}
|
package/src/commands/env.ts
CHANGED
package/src/commands/flags.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { AssistantClient } from "../lib/assistant-client.js";
|
|
2
|
+
import {
|
|
3
|
+
formatAssistantLookupError,
|
|
4
|
+
lookupAssistantByIdentifier,
|
|
5
|
+
} from "../lib/assistant-config.js";
|
|
2
6
|
|
|
3
7
|
type FeatureFlagEntry = {
|
|
4
8
|
key: string;
|
|
@@ -17,7 +21,12 @@ function pad(s: string, w: number): string {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
function printFlagTable(flags: FeatureFlagEntry[]): void {
|
|
20
|
-
const headers = {
|
|
24
|
+
const headers = {
|
|
25
|
+
key: "KEY",
|
|
26
|
+
enabled: "ENABLED",
|
|
27
|
+
default: "DEFAULT",
|
|
28
|
+
label: "LABEL",
|
|
29
|
+
};
|
|
21
30
|
|
|
22
31
|
const rows = flags
|
|
23
32
|
.slice()
|
|
@@ -55,7 +64,9 @@ function printHelp(): void {
|
|
|
55
64
|
console.log("Usage: vellum flags [subcommand] [options]");
|
|
56
65
|
console.log("");
|
|
57
66
|
console.log("Show and toggle feature flags for the active assistant.");
|
|
58
|
-
console.log(
|
|
67
|
+
console.log(
|
|
68
|
+
"Reads from the gateway's merged flag state (persisted overrides > remote > defaults).",
|
|
69
|
+
);
|
|
59
70
|
console.log("");
|
|
60
71
|
console.log("Subcommands:");
|
|
61
72
|
console.log(" (none) List all feature flags in a table");
|
|
@@ -63,20 +74,53 @@ function printHelp(): void {
|
|
|
63
74
|
console.log(" set <key> <bool> Set a flag override to true or false");
|
|
64
75
|
console.log("");
|
|
65
76
|
console.log("Options:");
|
|
77
|
+
console.log(
|
|
78
|
+
" --assistant <name> Target a specific assistant (display name or ID)",
|
|
79
|
+
);
|
|
80
|
+
console.log(
|
|
81
|
+
" instead of the active one. Useful for scripted",
|
|
82
|
+
);
|
|
83
|
+
console.log(
|
|
84
|
+
" flows like eval harnesses that must not mutate",
|
|
85
|
+
);
|
|
86
|
+
console.log(" the user's active-assistant pointer.");
|
|
66
87
|
console.log(" --help, -h Show this help");
|
|
67
88
|
console.log("");
|
|
68
89
|
console.log("Examples:");
|
|
69
|
-
console.log(
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
console.log(
|
|
91
|
+
" $ vellum flags # list flags for active assistant",
|
|
92
|
+
);
|
|
93
|
+
console.log(
|
|
94
|
+
" $ vellum flags get query-complexity-routing # inspect one flag",
|
|
95
|
+
);
|
|
96
|
+
console.log(
|
|
97
|
+
" $ vellum flags set voice-mode true # enable a flag",
|
|
98
|
+
);
|
|
99
|
+
console.log(
|
|
100
|
+
" $ vellum flags set external-plugins true --assistant eval-1 # target by name/id",
|
|
101
|
+
);
|
|
72
102
|
}
|
|
73
103
|
|
|
74
|
-
function createClient(): AssistantClient {
|
|
104
|
+
function createClient(assistantName?: string): AssistantClient {
|
|
105
|
+
// When `--assistant <name>` is provided, resolve the display name or
|
|
106
|
+
// explicit ID through the standard lookup helper (see cli/AGENTS.md
|
|
107
|
+
// "Assistant targeting convention"). Exact ID wins over display-name
|
|
108
|
+
// matches; ambiguous names fail loudly.
|
|
109
|
+
let assistantId: string | undefined;
|
|
110
|
+
if (assistantName) {
|
|
111
|
+
const result = lookupAssistantByIdentifier(assistantName);
|
|
112
|
+
if (result.status !== "found") {
|
|
113
|
+
throw new Error(formatAssistantLookupError(assistantName, result));
|
|
114
|
+
}
|
|
115
|
+
assistantId = result.entry.assistantId;
|
|
116
|
+
}
|
|
75
117
|
try {
|
|
76
|
-
return new AssistantClient();
|
|
118
|
+
return new AssistantClient(assistantId ? { assistantId } : undefined);
|
|
77
119
|
} catch {
|
|
78
120
|
throw new Error(
|
|
79
|
-
|
|
121
|
+
assistantName
|
|
122
|
+
? `No assistant found matching '${assistantName}'.`
|
|
123
|
+
: "No assistant found. Hatch one with 'vellum hatch' first.",
|
|
80
124
|
);
|
|
81
125
|
}
|
|
82
126
|
}
|
|
@@ -93,8 +137,8 @@ function rethrowFetchError(err: unknown): never {
|
|
|
93
137
|
throw err;
|
|
94
138
|
}
|
|
95
139
|
|
|
96
|
-
async function listFlags(): Promise<void> {
|
|
97
|
-
const client = createClient();
|
|
140
|
+
async function listFlags(assistantName?: string): Promise<void> {
|
|
141
|
+
const client = createClient(assistantName);
|
|
98
142
|
let res: Response;
|
|
99
143
|
try {
|
|
100
144
|
res = await client.get("/feature-flags");
|
|
@@ -113,8 +157,8 @@ async function listFlags(): Promise<void> {
|
|
|
113
157
|
printFlagTable(data.flags);
|
|
114
158
|
}
|
|
115
159
|
|
|
116
|
-
async function getFlag(key: string): Promise<void> {
|
|
117
|
-
const client = createClient();
|
|
160
|
+
async function getFlag(key: string, assistantName?: string): Promise<void> {
|
|
161
|
+
const client = createClient(assistantName);
|
|
118
162
|
let res: Response;
|
|
119
163
|
try {
|
|
120
164
|
res = await client.get("/feature-flags");
|
|
@@ -136,8 +180,12 @@ async function getFlag(key: string): Promise<void> {
|
|
|
136
180
|
console.log(`Description: ${flag.description || "(none)"}`);
|
|
137
181
|
}
|
|
138
182
|
|
|
139
|
-
async function setFlag(
|
|
140
|
-
|
|
183
|
+
async function setFlag(
|
|
184
|
+
key: string,
|
|
185
|
+
value: boolean,
|
|
186
|
+
assistantName?: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const client = createClient(assistantName);
|
|
141
189
|
let res: Response;
|
|
142
190
|
try {
|
|
143
191
|
res = await client.patch(`/feature-flags/${key}`, { enabled: value });
|
|
@@ -151,6 +199,28 @@ async function setFlag(key: string, value: boolean): Promise<void> {
|
|
|
151
199
|
console.log(`Flag "${key}" set to ${value}`);
|
|
152
200
|
}
|
|
153
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Strip `--assistant <name>` from argv and return the captured value.
|
|
204
|
+
*
|
|
205
|
+
* Mutates the input array so positional parsing downstream sees a clean
|
|
206
|
+
* shape (subcommand + key + value). Returns `undefined` if the flag is
|
|
207
|
+
* absent. Error-reports a missing value so the user gets a clear message
|
|
208
|
+
* rather than the flag being silently swallowed as a positional.
|
|
209
|
+
*/
|
|
210
|
+
function extractAssistantFlag(args: string[]): string | undefined {
|
|
211
|
+
for (let i = 0; i < args.length; i++) {
|
|
212
|
+
if (args[i] !== "--assistant") continue;
|
|
213
|
+
const value = args[i + 1];
|
|
214
|
+
if (!value || value.startsWith("-")) {
|
|
215
|
+
console.error("Missing value for --assistant <name>");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
args.splice(i, 2);
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
154
224
|
export async function flags(): Promise<void> {
|
|
155
225
|
const args = process.argv.slice(3);
|
|
156
226
|
|
|
@@ -159,10 +229,12 @@ export async function flags(): Promise<void> {
|
|
|
159
229
|
return;
|
|
160
230
|
}
|
|
161
231
|
|
|
232
|
+
const assistantName = extractAssistantFlag(args);
|
|
233
|
+
|
|
162
234
|
const subcommand = args[0];
|
|
163
235
|
|
|
164
236
|
if (!subcommand) {
|
|
165
|
-
await listFlags();
|
|
237
|
+
await listFlags(assistantName);
|
|
166
238
|
return;
|
|
167
239
|
}
|
|
168
240
|
|
|
@@ -172,7 +244,7 @@ export async function flags(): Promise<void> {
|
|
|
172
244
|
console.error("Usage: vellum flags get <key>");
|
|
173
245
|
process.exit(1);
|
|
174
246
|
}
|
|
175
|
-
await getFlag(key);
|
|
247
|
+
await getFlag(key, assistantName);
|
|
176
248
|
return;
|
|
177
249
|
}
|
|
178
250
|
|
|
@@ -187,7 +259,7 @@ export async function flags(): Promise<void> {
|
|
|
187
259
|
console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
|
|
188
260
|
process.exit(1);
|
|
189
261
|
}
|
|
190
|
-
await setFlag(key, rawValue === "true");
|
|
262
|
+
await setFlag(key, rawValue === "true", assistantName);
|
|
191
263
|
return;
|
|
192
264
|
}
|
|
193
265
|
|