@beryl-so/cli 0.22.0 → 0.24.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 +7 -5
- package/dist/commands/auth.js +80 -24
- package/dist/commands/init.js +25 -8
- package/dist/device-login.js +132 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ that exposes every command to coding agents.
|
|
|
12
12
|
npx @beryl-so/cli@latest init
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Signs you in
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Safe to re-run.
|
|
15
|
+
Signs you in (browser approval by default, emailed one-time code as fallback — new
|
|
16
|
+
emails are signed up on the spot), wires the beryl + playwright MCP servers into Claude
|
|
17
|
+
Code or Cursor, installs the beryl-test authoring skill, and installs Playwright for
|
|
18
|
+
local runs. Safe to re-run.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
@@ -33,7 +33,8 @@ latest npm release — a stale MCP server silently exposes fewer tools. Set
|
|
|
33
33
|
## Authenticate
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
beryl login #
|
|
36
|
+
beryl login # opens the browser to approve — press Enter for an emailed code instead
|
|
37
|
+
beryl login --otp # skip the browser; emailed one-time code (signs up new emails too)
|
|
37
38
|
export BERYL_API_KEY=beryl_pat_… # CI: use a token from Account → API tokens
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -120,6 +121,7 @@ Manage the personal access tokens that authenticate the CLI and CI.
|
|
|
120
121
|
| `beryl tokens list` | List your personal access tokens | `tokens_list` |
|
|
121
122
|
| `beryl tokens create <name>` | Mint a new personal access token (shown once) | `tokens_create` |
|
|
122
123
|
| `beryl tokens revoke <token-id>` | Revoke a personal access token | `tokens_revoke` |
|
|
124
|
+
| `beryl tokens dismiss <token-id>` | Remove an already-revoked token from your list | `tokens_dismiss` |
|
|
123
125
|
|
|
124
126
|
### workspaces
|
|
125
127
|
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import { saveGlobalConfig } from "../config.js";
|
|
4
|
+
import { deviceLogin, SWITCH_TO_OTP } from "../device-login.js";
|
|
4
5
|
import { CliError, UsageError } from "../errors.js";
|
|
5
6
|
import { ApiClient } from "../http.js";
|
|
6
7
|
import { dim, green } from "../output.js";
|
|
@@ -48,18 +49,67 @@ export async function promptForOtpCode(ctx) {
|
|
|
48
49
|
throw new UsageError("No valid 6-digit code entered");
|
|
49
50
|
return code;
|
|
50
51
|
}
|
|
52
|
+
// The emailed-code flow, returning a raw PAT. With a --code the email already has a
|
|
53
|
+
// code, so nothing is sent (and check-email is skipped entirely).
|
|
54
|
+
async function otpLogin(ctx, input, codeFlag) {
|
|
55
|
+
const apiUrl = ctx.client.baseUrl;
|
|
56
|
+
const email = flagStr(input, "email") ?? (await promptForEmail(ctx));
|
|
57
|
+
if (!email.includes("@"))
|
|
58
|
+
throw new UsageError(`"${email}" is not an email address`);
|
|
59
|
+
const anon = new ApiClient(apiUrl);
|
|
60
|
+
let code = codeFlag;
|
|
61
|
+
if (!code) {
|
|
62
|
+
const check = (await anon.post("/auth/check-email", { email }));
|
|
63
|
+
if (!check.exists) {
|
|
64
|
+
await anon.post("/auth/signup", { email });
|
|
65
|
+
ctx.err(dim(`No Beryl account for ${email} yet — creating one. Sent a 6-digit code to ${email}`));
|
|
66
|
+
}
|
|
67
|
+
else if ((check.auth_methods ?? []).includes("otp")) {
|
|
68
|
+
await anon.post("/auth/request-login-otp", { email });
|
|
69
|
+
ctx.err(dim(`Sent a 6-digit code to ${email}`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const why = `${email} signs in with Google, so there is no emailed code`;
|
|
73
|
+
if (!ctx.interactive) {
|
|
74
|
+
throw new UsageError(`${why} — run \`beryl login\` in a terminal to sign in via the browser, ` +
|
|
75
|
+
"or pass --token with a token from beryl.so → Account → API tokens");
|
|
76
|
+
}
|
|
77
|
+
ctx.err(`${why} — confirm the sign-in in your browser instead.`);
|
|
78
|
+
const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
|
|
79
|
+
if (fromBrowser === SWITCH_TO_OTP)
|
|
80
|
+
throw new UsageError(why);
|
|
81
|
+
return fromBrowser;
|
|
82
|
+
}
|
|
83
|
+
code = await promptForOtpCode(ctx);
|
|
84
|
+
}
|
|
85
|
+
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
86
|
+
if (!login.access_token)
|
|
87
|
+
throw new CliError("Login did not return an access token");
|
|
88
|
+
const session = new ApiClient(apiUrl, login.access_token);
|
|
89
|
+
const minted = (await session.post("/account/tokens", {
|
|
90
|
+
name: flagStr(input, "token-name") ?? `CLI on ${os.hostname()}`,
|
|
91
|
+
}));
|
|
92
|
+
return minted.token;
|
|
93
|
+
}
|
|
51
94
|
export const authCommands = [
|
|
52
95
|
{
|
|
53
96
|
name: "login",
|
|
54
97
|
summary: "Authenticate the CLI with your Beryl account",
|
|
55
|
-
description: "Signs in
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
98
|
+
description: "Signs in via your browser (a code you confirm at beryl.so) and stores a personal " +
|
|
99
|
+
"access token in the CLI config. Pass --otp (or --email) to skip the browser and " +
|
|
100
|
+
"sign in with an emailed one-time code instead — a new email gets an account " +
|
|
101
|
+
"created automatically. Pass --token to use an existing token from Account → API " +
|
|
102
|
+
"tokens. Pass --email plus --code (the 6 digits from the email, e.g. read from a " +
|
|
103
|
+
"`beryl inbox`) to complete the OTP flow without a prompt. In CI, prefer the " +
|
|
104
|
+
"BERYL_API_KEY environment variable.",
|
|
60
105
|
interactive: true,
|
|
61
106
|
flags: [
|
|
62
107
|
{ name: "token", type: "string", description: "Use an existing personal access token" },
|
|
108
|
+
{
|
|
109
|
+
name: "otp",
|
|
110
|
+
type: "boolean",
|
|
111
|
+
description: "Skip the browser — sign in with an emailed code",
|
|
112
|
+
},
|
|
63
113
|
{
|
|
64
114
|
name: "email",
|
|
65
115
|
type: "string",
|
|
@@ -81,6 +131,7 @@ export const authCommands = [
|
|
|
81
131
|
],
|
|
82
132
|
examples: [
|
|
83
133
|
"beryl login",
|
|
134
|
+
"beryl login --otp",
|
|
84
135
|
"beryl login --token beryl_pat_…",
|
|
85
136
|
"beryl login --email you@example.com",
|
|
86
137
|
"beryl login --email agent@example.com --code 123456 --json",
|
|
@@ -96,26 +147,22 @@ export const authCommands = [
|
|
|
96
147
|
if (!flagStr(input, "email"))
|
|
97
148
|
throw new UsageError("--code requires --email");
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
150
|
+
// Guard before any network call — the OTP flow's check-email/signup would create
|
|
151
|
+
// a real account and send an email before dying on the interactive code prompt.
|
|
152
|
+
if (!ctx.interactive && codeFlag === undefined) {
|
|
153
|
+
throw new UsageError("Cannot prompt for a code without a terminal — pass --email with --code, " +
|
|
154
|
+
"--token, or set BERYL_API_KEY");
|
|
155
|
+
}
|
|
156
|
+
const wantsOtp = flagBool(input, "otp") ||
|
|
157
|
+
flagStr(input, "email") !== undefined ||
|
|
158
|
+
codeFlag !== undefined;
|
|
159
|
+
if (!wantsOtp && ctx.interactive) {
|
|
160
|
+
const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
|
|
161
|
+
token = fromBrowser === SWITCH_TO_OTP ? await otpLogin(ctx, input) : fromBrowser;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
token = await otpLogin(ctx, input, codeFlag);
|
|
110
165
|
}
|
|
111
|
-
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
112
|
-
if (!login.access_token)
|
|
113
|
-
throw new CliError("Login did not return an access token");
|
|
114
|
-
const session = new ApiClient(apiUrl, login.access_token);
|
|
115
|
-
const minted = (await session.post("/account/tokens", {
|
|
116
|
-
name: flagStr(input, "token-name") ?? `CLI on ${os.hostname()}`,
|
|
117
|
-
}));
|
|
118
|
-
token = minted.token;
|
|
119
166
|
}
|
|
120
167
|
const authed = new ApiClient(apiUrl, token);
|
|
121
168
|
const me = (await authed.get("/account/"));
|
|
@@ -240,4 +287,13 @@ export const authCommands = [
|
|
|
240
287
|
return { human: "Revoked." };
|
|
241
288
|
},
|
|
242
289
|
},
|
|
290
|
+
{
|
|
291
|
+
name: "tokens dismiss",
|
|
292
|
+
summary: "Remove an already-revoked token from your list",
|
|
293
|
+
args: [{ name: "token-id", description: "Token id from `beryl tokens list`", required: true }],
|
|
294
|
+
async run(ctx, input) {
|
|
295
|
+
await ctx.client.post(`/account/tokens/${arg(input, "token-id")}/dismiss`, {});
|
|
296
|
+
return { human: "Dismissed." };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
243
299
|
];
|
package/dist/commands/init.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
|
-
import { CliError } from "../errors.js";
|
|
7
|
+
import { AuthError, CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
9
9
|
import { bold, cyan, dim, green, red, yellow } from "../output.js";
|
|
10
10
|
import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
@@ -148,7 +148,8 @@ export const initCommands = [
|
|
|
148
148
|
{
|
|
149
149
|
name: "init",
|
|
150
150
|
summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
|
|
151
|
-
description: "One-command onboarding: signs you in (emailed one-time code)
|
|
151
|
+
description: "One-command onboarding: signs you in (browser confirm, or an emailed one-time code) " +
|
|
152
|
+
"and wires up your coding " +
|
|
152
153
|
"agent. Nothing is detected and nothing is conditional — every run wires the beryl AND " +
|
|
153
154
|
"playwright MCP servers, writes the authoring skill (user scope: your home " +
|
|
154
155
|
".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
|
|
@@ -186,17 +187,33 @@ export const initCommands = [
|
|
|
186
187
|
async run(ctx, input) {
|
|
187
188
|
const cwd = process.cwd();
|
|
188
189
|
let { client, config } = ctx;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
const signIn = async () => {
|
|
191
|
+
// Non-interactive skips the picker and lets `login` fail with its own
|
|
192
|
+
// "interactive input required" message, exactly as before.
|
|
193
|
+
const answer = ctx.interactive
|
|
194
|
+
? await ctx.prompt("Sign in: [1] Browser (opens beryl.so) [2] Email me a code — [1]: ")
|
|
195
|
+
: "";
|
|
194
196
|
const login = authCommands.find((c) => c.name === "login");
|
|
195
|
-
await login.run(ctx, { args: {}, flags: {} });
|
|
197
|
+
await login.run(ctx, { args: {}, flags: answer.trim() === "2" ? { otp: true } : {} });
|
|
196
198
|
config = loadConfig();
|
|
197
199
|
if (!config.token)
|
|
198
200
|
throw new CliError("Login did not persist a token");
|
|
199
201
|
client = new ApiClient(config.apiUrl, config.token);
|
|
202
|
+
};
|
|
203
|
+
if (config.token) {
|
|
204
|
+
try {
|
|
205
|
+
const me = (await client.get("/account/"));
|
|
206
|
+
ctx.err(`${green("✓")} Signed in as ${me.email}`);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (!(err instanceof AuthError))
|
|
210
|
+
throw err;
|
|
211
|
+
ctx.err(yellow("Your session token is invalid or expired."));
|
|
212
|
+
await signIn();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
await signIn();
|
|
200
217
|
}
|
|
201
218
|
const scope = (flagStr(input, "scope") ?? "user");
|
|
202
219
|
const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { ApiClient, ApiError } from "./http.js";
|
|
5
|
+
import { dim } from "./output.js";
|
|
6
|
+
/** Returned when the user presses Enter to fall back to the emailed-code flow. */
|
|
7
|
+
export const SWITCH_TO_OTP = Symbol("switch-to-otp");
|
|
8
|
+
function openBrowser(url) {
|
|
9
|
+
const [cmd, args] = process.platform === "darwin"
|
|
10
|
+
? ["open", [url]]
|
|
11
|
+
: process.platform === "win32"
|
|
12
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
13
|
+
: ["xdg-open", [url]];
|
|
14
|
+
try {
|
|
15
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
16
|
+
child.on("error", () => { });
|
|
17
|
+
child.unref();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// no browser is fine — the URL is printed for copy-paste
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Browser device-code sign-in: opens beryl.so, polls until the user confirms the code
|
|
24
|
+
* there, and resolves to the raw PAT — or to SWITCH_TO_OTP on Enter. */
|
|
25
|
+
export async function deviceLogin(ctx, stdin = process.stdin, tokenName) {
|
|
26
|
+
const anon = new ApiClient(ctx.client.baseUrl);
|
|
27
|
+
const start = (await anon.post("/auth/cli/start", {
|
|
28
|
+
token_name: tokenName ?? `CLI on ${os.hostname()}`,
|
|
29
|
+
}));
|
|
30
|
+
openBrowser(start.verification_url);
|
|
31
|
+
ctx.err(start.verification_url);
|
|
32
|
+
ctx.err(`Confirm this code in the browser: ${start.user_code}`);
|
|
33
|
+
ctx.err(dim("Press Enter to sign in with an emailed code instead"));
|
|
34
|
+
let timer;
|
|
35
|
+
let onData;
|
|
36
|
+
let done = false;
|
|
37
|
+
try {
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
let interval = start.interval;
|
|
40
|
+
let failures = 0;
|
|
41
|
+
let pollInFlight = false;
|
|
42
|
+
let switchRequested = false;
|
|
43
|
+
const finish = (token) => {
|
|
44
|
+
done = true;
|
|
45
|
+
resolve(token);
|
|
46
|
+
};
|
|
47
|
+
const fail = (err) => {
|
|
48
|
+
done = true;
|
|
49
|
+
reject(err);
|
|
50
|
+
};
|
|
51
|
+
const switchToOtp = () => {
|
|
52
|
+
done = true;
|
|
53
|
+
resolve(SWITCH_TO_OTP);
|
|
54
|
+
};
|
|
55
|
+
onData = (chunk) => {
|
|
56
|
+
if (!/[\r\n]/.test(chunk.toString()))
|
|
57
|
+
return;
|
|
58
|
+
if (done || switchRequested)
|
|
59
|
+
return;
|
|
60
|
+
// An in-flight poll may already be approved server-side (the session is consumed);
|
|
61
|
+
// its result must win or the minted token is lost forever.
|
|
62
|
+
if (pollInFlight)
|
|
63
|
+
switchRequested = true;
|
|
64
|
+
else
|
|
65
|
+
switchToOtp();
|
|
66
|
+
};
|
|
67
|
+
stdin.on("data", onData);
|
|
68
|
+
stdin.resume();
|
|
69
|
+
const poll = async () => {
|
|
70
|
+
pollInFlight = true;
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = (await anon.post("/auth/cli/poll", {
|
|
74
|
+
device_code: start.device_code,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
pollInFlight = false;
|
|
79
|
+
if (done)
|
|
80
|
+
return;
|
|
81
|
+
if (switchRequested) {
|
|
82
|
+
switchToOtp();
|
|
83
|
+
}
|
|
84
|
+
else if (++failures >= 5) {
|
|
85
|
+
fail(err);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (err instanceof ApiError && err.status === 429)
|
|
89
|
+
interval *= 2;
|
|
90
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pollInFlight = false;
|
|
95
|
+
failures = 0;
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
if (res.status === "approved" && res.token) {
|
|
99
|
+
if (switchRequested)
|
|
100
|
+
ctx.err("Already approved in the browser — signed in");
|
|
101
|
+
finish(res.token);
|
|
102
|
+
}
|
|
103
|
+
else if (switchRequested) {
|
|
104
|
+
switchToOtp();
|
|
105
|
+
}
|
|
106
|
+
else if (res.status === "approved") {
|
|
107
|
+
fail(new CliError("Sign-in approved but no token was returned — try again"));
|
|
108
|
+
}
|
|
109
|
+
else if (res.status === "denied") {
|
|
110
|
+
fail(new CliError("Sign-in was denied in the browser"));
|
|
111
|
+
}
|
|
112
|
+
else if (res.status !== "pending") {
|
|
113
|
+
fail(new CliError("The sign-in request expired — run `beryl login` again"));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Both racers are torn down whichever wins — a live timer or stdin listener would
|
|
124
|
+
// keep the process alive after login.
|
|
125
|
+
done = true;
|
|
126
|
+
if (timer)
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
if (onData)
|
|
129
|
+
stdin.off("data", onData);
|
|
130
|
+
stdin.pause();
|
|
131
|
+
}
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Beryl on the command line
|
|
3
|
+
"version": "0.24.0",
|
|
4
|
+
"description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"homepage": "https://beryl.so/docs/cli",
|