Skip to content

Commit e9b29ac

Browse files
committed
Add auth status command and scope guidance
1 parent 05c9b9c commit e9b29ac

9 files changed

Lines changed: 220 additions & 2 deletions

File tree

API.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,61 @@ This keeps automation predictable while allowing full passthrough.
111111

112112
## MVP commands
113113

114+
## `auth.status`
115+
116+
Read the token details and report whether required scopes are present.
117+
118+
Required scopes for this CLI:
119+
120+
- `read_builds`
121+
- `read_build_logs`
122+
- `read_artifacts`
123+
124+
### Success response
125+
126+
```json
127+
{
128+
"ok": true,
129+
"apiVersion": "v1",
130+
"command": "auth.status",
131+
"request": {},
132+
"summary": {
133+
"requiredScopes": [
134+
"read_builds",
135+
"read_build_logs",
136+
"read_artifacts"
137+
],
138+
"grantedScopes": 3,
139+
"missingScopes": [],
140+
"ready": true
141+
},
142+
"pagination": null,
143+
"data": {
144+
"token": {
145+
"uuid": "019c...",
146+
"description": "local cli token",
147+
"createdAt": "2026-02-08T20:15:32Z",
148+
"scopes": [
149+
"read_builds",
150+
"read_build_logs",
151+
"read_artifacts"
152+
]
153+
},
154+
"user": {
155+
"name": "Pat Example",
156+
"email": "pat@example.com"
157+
},
158+
"requiredScopes": [
159+
"read_builds",
160+
"read_build_logs",
161+
"read_artifacts"
162+
],
163+
"missingScopes": []
164+
},
165+
"error": null
166+
}
167+
```
168+
114169
## `builds.list`
115170

116171
List builds globally, by organization, or by pipeline.
@@ -442,6 +497,7 @@ List build annotations.
442497

443498
## Mapping to Buildkite REST endpoints
444499

500+
- `auth.status` -> `GET /v2/access-token`
445501
- `builds.list` -> `GET /v2/builds` or `GET /v2/organizations/{org}/builds` or `GET /v2/organizations/{org}/pipelines/{pipeline}/builds`
446502
- `builds.get` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}`
447503
- `jobs.log.get` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}/jobs/{job.id}/log`
@@ -460,4 +516,3 @@ These are strong candidates for the next version:
460516
- `jobs.unblock`
461517
- `jobs.env.get`
462518
- `pipelines.list`
463-
- `auth.status`

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Set one of these environment variables:
4040
## Commands
4141

4242
```bash
43+
bkci auth status
4344
bkci builds list --org ORG [--pipeline PIPELINE] [--branch BRANCH] [--state STATE]
4445
bkci builds get --org ORG --pipeline PIPELINE --build BUILD_NUMBER
4546
bkci jobs log get --org ORG --pipeline PIPELINE --build BUILD_NUMBER --job JOB_ID
@@ -48,8 +49,15 @@ bkci artifacts download --org ORG --pipeline PIPELINE --build BUILD_NUMBER [--jo
4849
bkci annotations list --org ORG --pipeline PIPELINE --build BUILD_NUMBER
4950
```
5051

52+
## Required token scopes
53+
54+
- `read_builds`
55+
- `read_build_logs`
56+
- `read_artifacts`
57+
5158
## Notes
5259

5360
- Use `--raw` on any command to return raw Buildkite payloads inside the envelope.
61+
- `bkci auth status` checks token scopes and reports missing required scopes.
5462
- `jobs log get` strips ANSI/control sequences in normalized mode for cleaner LLM output.
5563
- Pagination metadata is parsed from Buildkite `Link` headers for list endpoints.

src/cli/execute-command.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,42 @@ function createMockClient(options: {
4848
};
4949
}
5050

51+
test("executeCommand auth.status returns required scope diagnostics", async () => {
52+
const command = parseCliArgs(["auth", "status"]);
53+
54+
const client = createMockClient({
55+
jsonResponses: [
56+
{
57+
status: 200,
58+
headers: new Headers(),
59+
requestId: "req-auth",
60+
data: {
61+
uuid: "token-1",
62+
description: "Agent token",
63+
scopes: ["read_builds", "read_build_logs"],
64+
created_at: "2026-02-08T20:00:00Z",
65+
user: {
66+
name: "Pat",
67+
email: "pat@example.com",
68+
},
69+
},
70+
},
71+
],
72+
});
73+
74+
const result = await executeCommand({ command, client });
75+
76+
assert.deepEqual(result.summary, {
77+
requiredScopes: ["read_builds", "read_build_logs", "read_artifacts"],
78+
grantedScopes: 2,
79+
missingScopes: ["read_artifacts"],
80+
ready: false,
81+
});
82+
83+
const data = result.data as Record<string, unknown>;
84+
assert.deepEqual(data.missingScopes, ["read_artifacts"]);
85+
});
86+
5187
test("executeCommand builds.list normalizes data and pagination", async () => {
5288
const command = parseCliArgs(["builds", "list", "--org", "acme", "--pipeline", "web"]);
5389
const headers = new Headers({

src/cli/execute-command.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,59 @@ function mapAnnotation(annotation: unknown): Record<string, unknown> {
136136
};
137137
}
138138

139+
function asStringArray(value: unknown): Array<string> {
140+
if (!Array.isArray(value)) {
141+
return [];
142+
}
143+
144+
const strings: Array<string> = [];
145+
for (const item of value) {
146+
if (typeof item === "string") {
147+
strings.push(item);
148+
}
149+
}
150+
151+
return strings;
152+
}
153+
154+
const REQUIRED_SCOPES: ReadonlyArray<string> = [
155+
"read_builds",
156+
"read_build_logs",
157+
"read_artifacts",
158+
];
159+
160+
function mapAuthStatus(data: unknown): {
161+
readonly token: Record<string, unknown>;
162+
readonly user: Record<string, unknown> | null;
163+
readonly scopes: Array<string>;
164+
} {
165+
const record = isObject(data) ? data : {};
166+
const scopes = asStringArray(record.scopes);
167+
const token = {
168+
uuid: asString(record.uuid),
169+
description: asString(record.description),
170+
createdAt: asString(record.created_at),
171+
scopes,
172+
};
173+
174+
const user = isObject(record.user)
175+
? {
176+
name: asString(record.user.name),
177+
email: asString(record.user.email),
178+
}
179+
: null;
180+
181+
return {
182+
token,
183+
user,
184+
scopes,
185+
};
186+
}
187+
188+
function getMissingRequiredScopes(scopes: ReadonlyArray<string>): Array<string> {
189+
return REQUIRED_SCOPES.filter((scope) => !scopes.includes(scope));
190+
}
191+
139192
function getPaginationOrNull(options: {
140193
readonly command: ParsedCommand;
141194
readonly headers: Headers;
@@ -246,6 +299,41 @@ export async function executeCommand(options: {
246299
}> {
247300
const request = getRequestForCommand(options.command);
248301

302+
if (options.command.name === "auth.status") {
303+
const response = await options.client.requestJson({
304+
path: "/v2/access-token",
305+
});
306+
307+
if (options.command.global.raw) {
308+
return {
309+
request,
310+
summary: {},
311+
pagination: null,
312+
data: response.data,
313+
};
314+
}
315+
316+
const status = mapAuthStatus(response.data);
317+
const missingScopes = getMissingRequiredScopes(status.scopes);
318+
319+
return {
320+
request,
321+
summary: {
322+
requiredScopes: [...REQUIRED_SCOPES],
323+
grantedScopes: status.scopes.length,
324+
missingScopes,
325+
ready: missingScopes.length === 0,
326+
},
327+
pagination: null,
328+
data: {
329+
token: status.token,
330+
user: status.user,
331+
requiredScopes: [...REQUIRED_SCOPES],
332+
missingScopes,
333+
},
334+
};
335+
}
336+
249337
if (options.command.name === "builds.list") {
250338
const response = await options.client.requestJson({
251339
path: getBuildsListPath(options.command.args),

src/cli/parse-args.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import test from "node:test";
33

44
import { parseCliArgs } from "./parse-args.js";
55

6+
test("parseCliArgs parses auth status", () => {
7+
const parsed = parseCliArgs(["auth", "status"]);
8+
9+
assert.equal(parsed.name, "auth.status");
10+
assert.equal(parsed.global.raw, false);
11+
assert.deepEqual(parsed.args, {});
12+
});
13+
614
test("parseCliArgs parses builds list with global raw option", () => {
715
const parsed = parseCliArgs([
816
"--raw",

src/cli/parse-args.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AnnotationsListArgs,
33
ArtifactsDownloadArgs,
44
ArtifactsListArgs,
5+
AuthStatusArgs,
56
BuildsGetArgs,
67
BuildsListArgs,
78
JobsLogGetArgs,
@@ -59,6 +60,13 @@ function parseGlobalOptions(tokens: Array<string>): { readonly global: ParsedGlo
5960
};
6061
}
6162

63+
function parseAuthStatus(tokens: Array<string>): AuthStatusArgs {
64+
if (tokens.length > 0) {
65+
throw new Error(`unknown argument for auth status: ${tokens[0] ?? ""}`);
66+
}
67+
return {};
68+
}
69+
6270
function parseBuildsList(tokens: Array<string>): BuildsListArgs {
6371
let org: string | null = null;
6472
let pipeline: string | null = null;
@@ -405,6 +413,14 @@ export function parseCliArgs(argv: Array<string>): ParsedCommand {
405413

406414
const [group, action, ...rest] = tokens;
407415

416+
if (group === "auth" && action === "status") {
417+
return {
418+
name: "auth.status",
419+
global: globalParsed.global,
420+
args: parseAuthStatus(rest),
421+
};
422+
}
423+
408424
if (group === "builds" && action === "list") {
409425
return {
410426
name: "builds.list",
@@ -460,6 +476,7 @@ export function parseCliArgs(argv: Array<string>): ParsedCommand {
460476
}
461477

462478
export const USAGE_TEXT = `Usage:
479+
bkci auth status [--raw]
463480
bkci builds list --org ORG [--pipeline PIPELINE] [--branch BRANCH] [--state STATE] [--page N] [--per-page N] [--raw]
464481
bkci builds get --org ORG --pipeline PIPELINE --build BUILD_NUMBER [--raw]
465482
bkci jobs log get --org ORG --pipeline PIPELINE --build BUILD_NUMBER --job JOB_ID [--max-bytes N] [--tail-lines N] [--raw]

src/cli/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export type ParsedGlobalOptions = {
22
readonly raw: boolean;
33
};
44

5+
export type AuthStatusArgs = Record<string, never>;
6+
57
export type BuildsListArgs = {
68
readonly org: string;
79
readonly pipeline: string | null;
@@ -50,6 +52,7 @@ export type AnnotationsListArgs = {
5052
};
5153

5254
export type ParsedCommand =
55+
| { readonly name: "auth.status"; readonly global: ParsedGlobalOptions; readonly args: AuthStatusArgs }
5356
| { readonly name: "builds.list"; readonly global: ParsedGlobalOptions; readonly args: BuildsListArgs }
5457
| { readonly name: "builds.get"; readonly global: ParsedGlobalOptions; readonly args: BuildsGetArgs }
5558
| { readonly name: "jobs.log.get"; readonly global: ParsedGlobalOptions; readonly args: JobsLogGetArgs }

src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type CommandName =
2+
| "auth.status"
23
| "builds.list"
34
| "builds.get"
45
| "jobs.log.get"

src/shell/env.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ export function getBuildkiteTokenFromEnv(): string {
66
null;
77

88
if (token === null || token.trim().length === 0) {
9-
throw new Error("missing buildkite token in env (BUILDKITE_TOKEN, BUILDKITE_API_TOKEN, BK_TOKEN)");
9+
throw new Error(
10+
"missing buildkite token in env (BUILDKITE_TOKEN, BUILDKITE_API_TOKEN, BK_TOKEN). create one at https://buildkite.com/user/api-access-tokens with scopes: read_builds, read_build_logs, read_artifacts"
11+
);
1012
}
1113

1214
return token.trim();

0 commit comments

Comments
 (0)