@braingrid/cli 0.2.25 → 0.2.27

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/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.27] - 2025-01-27
11
+
12
+ ### Added
13
+
14
+ - **Requirement tagging support**
15
+ - New `--tags` option for `requirement create` and `specify` commands
16
+ - Accepts comma-separated tags (max 5 per requirement)
17
+ - Tags are validated, trimmed, and empty values filtered
18
+ - Tags displayed in all output formats (table, markdown, XML, JSON)
19
+
20
+ ## [0.2.26] - 2025-01-20
21
+
22
+ ### Added
23
+
24
+ - **BRAINGRID_API_TOKEN environment variable support**
25
+ - Enables authentication via JWT token for sandbox/CI environments
26
+ - Allows CLI usage without interactive OAuth login flow
27
+ - Useful for automated pipelines and testing scenarios
28
+
29
+ ### Changed
30
+
31
+ - **README documentation updates**
32
+ - Added `requirement create-branch` and `requirement review` commands to docs
33
+ - Updated shell completion subcommands list
34
+
10
35
  ## [0.2.25] - 2025-12-22
11
36
 
12
37
  ### Added
package/README.md CHANGED
@@ -213,6 +213,8 @@ braingrid requirement update [id] [--status IDEA|PLANNED|IN_PROGRESS|REVIEW|COMP
213
213
  braingrid requirement delete [id] [--force]
214
214
  braingrid requirement breakdown [id]
215
215
  braingrid requirement build [id] [--format markdown|json|xml]
216
+ braingrid requirement create-branch [id] [--name <branch-name>] [--base <branch>]
217
+ braingrid requirement review [id] [--pr <number>]
216
218
 
217
219
  # Working with a different project:
218
220
  braingrid requirement list -p PROJ-456 [--status PLANNED]
@@ -226,6 +228,10 @@ braingrid requirement create -p PROJ-456 --name "Description"
226
228
  > **Note:** The `-r`/`--requirement` parameter is optional and accepts formats like `REQ-456`, `req-456`, or `456`. The CLI will automatically detect the requirement ID from your git branch name (e.g., `feature/REQ-123-description` or `REQ-123-fix-bug`) if it is not provided.
227
229
  >
228
230
  > **Note:** The `requirement list` command displays requirements with their status, name, branch (if assigned), and progress percentage.
231
+ >
232
+ > **Note:** The `create-branch` command creates a GitHub branch for a requirement. It auto-generates a branch name in the format `{username}/REQ-123-slug` if not provided.
233
+ >
234
+ > **Note:** The `review` command runs an AI-powered acceptance review on a pull request, validating it against the requirement's acceptance criteria. It auto-detects the PR number from the current branch if not provided.
229
235
 
230
236
  ### Task Commands
231
237
 
@@ -309,7 +315,7 @@ eval "$(braingrid completion zsh)"
309
315
  ### What Gets Completed
310
316
 
311
317
  - **Commands**: `login`, `logout`, `project`, `requirement`, `task`, etc.
312
- - **Subcommands**: `list`, `show`, `create`, `update`, `delete`, `breakdown`, `build`
318
+ - **Subcommands**: `list`, `show`, `create`, `update`, `delete`, `breakdown`, `build`, `create-branch`, `review`
313
319
  - **Options**: `--help`, `--format`, `--status`, `--project`, `--requirement`
314
320
  - **Values**: Status values (`IDEA`, `PLANNED`, `IN_PROGRESS`, etc.), format options (`table`, `json`, `xml`, `markdown`)
315
321
 
package/dist/cli.js CHANGED
@@ -23,6 +23,81 @@ import chalk6 from "chalk";
23
23
  // src/utils/axios-with-auth.ts
24
24
  import axios from "axios";
25
25
 
26
+ // src/build-config.ts
27
+ var BUILD_ENV = true ? "production" : process.env.NODE_ENV === "test" ? "development" : "production";
28
+ var CLI_VERSION = true ? "0.2.27" : "0.0.0-test";
29
+ var PRODUCTION_CONFIG = {
30
+ apiUrl: "https://app.braingrid.ai",
31
+ workosAuthUrl: "https://auth.braingrid.ai",
32
+ workosClientId: "client_01K6H010C9K69HSDPM9CQM85S7"
33
+ };
34
+ var DEVELOPMENT_CONFIG = {
35
+ apiUrl: "https://app.dev.braingrid.ai",
36
+ workosAuthUrl: "https://balanced-celebration-78-staging.authkit.app",
37
+ workosClientId: "client_01K6H04GF21T4JXNS3JDQM3YNE"
38
+ };
39
+
40
+ // src/utils/config.ts
41
+ var BRAINGRID_API_TOKEN = process.env.BRAINGRID_API_TOKEN;
42
+ function getConfig() {
43
+ const baseConfig = BUILD_ENV === "production" ? PRODUCTION_CONFIG : DEVELOPMENT_CONFIG;
44
+ let apiUrl = baseConfig.apiUrl;
45
+ if (BUILD_ENV === "development") {
46
+ if (process.env.NODE_ENV === "local" || process.env.NODE_ENV === "test") {
47
+ apiUrl = "http://localhost:3377";
48
+ } else if (process.env.NODE_ENV === "development") {
49
+ apiUrl = DEVELOPMENT_CONFIG.apiUrl;
50
+ }
51
+ }
52
+ const getWorkOSAuthUrl = () => {
53
+ if (process.env.WORKOS_AUTH_URL) {
54
+ return process.env.WORKOS_AUTH_URL;
55
+ }
56
+ if (BUILD_ENV === "production") {
57
+ return PRODUCTION_CONFIG.workosAuthUrl;
58
+ }
59
+ const env = process.env.NODE_ENV || "development";
60
+ if (env === "local" || env === "test" || env === "development" || env === "staging") {
61
+ return DEVELOPMENT_CONFIG.workosAuthUrl;
62
+ }
63
+ return PRODUCTION_CONFIG.workosAuthUrl;
64
+ };
65
+ const getOAuthClientId = () => {
66
+ if (process.env.WORKOS_CLIENT_ID) {
67
+ return process.env.WORKOS_CLIENT_ID;
68
+ }
69
+ if (BUILD_ENV === "production") {
70
+ return PRODUCTION_CONFIG.workosClientId;
71
+ }
72
+ const env = process.env.NODE_ENV || "development";
73
+ if (env === "local" || env === "test" || env === "development" || env === "staging") {
74
+ return DEVELOPMENT_CONFIG.workosClientId;
75
+ }
76
+ return PRODUCTION_CONFIG.workosClientId;
77
+ };
78
+ const getWebAppUrl = () => {
79
+ if (process.env.BRAINGRID_WEB_URL) {
80
+ return process.env.BRAINGRID_WEB_URL;
81
+ }
82
+ if (BUILD_ENV === "production") {
83
+ return PRODUCTION_CONFIG.apiUrl;
84
+ }
85
+ const env = process.env.NODE_ENV || "development";
86
+ if (env === "local" || env === "test") {
87
+ return "http://localhost:3377";
88
+ }
89
+ return DEVELOPMENT_CONFIG.apiUrl;
90
+ };
91
+ return {
92
+ apiUrl: process.env.BRAINGRID_API_URL || apiUrl,
93
+ organizationId: process.env.BRAINGRID_ORG_ID,
94
+ clientId: process.env.BRAINGRID_CLIENT_ID || "braingrid-cli",
95
+ oauthClientId: getOAuthClientId(),
96
+ getWorkOSAuthUrl,
97
+ getWebAppUrl
98
+ };
99
+ }
100
+
26
101
  // src/utils/logger.ts
27
102
  import fs from "fs";
28
103
  import path from "path";
@@ -211,6 +286,10 @@ function createAuthenticatedAxios(auth) {
211
286
  });
212
287
  instance.interceptors.request.use(
213
288
  async (config) => {
289
+ if (BRAINGRID_API_TOKEN) {
290
+ config.headers.Authorization = `Bearer ${BRAINGRID_API_TOKEN}`;
291
+ return config;
292
+ }
214
293
  const session = await auth.getStoredSession();
215
294
  if (session) {
216
295
  config.headers.Authorization = `Bearer ${session.sealed_session}`;
@@ -251,6 +330,11 @@ function createAuthenticatedAxios(auth) {
251
330
  const isRedirectToAuth = isAuthRedirect(error);
252
331
  if ((is401 || isRedirectToAuth) && !originalRequest._retry) {
253
332
  originalRequest._retry = true;
333
+ if (BRAINGRID_API_TOKEN) {
334
+ logger.warn("[AUTH] Sandbox token expired or invalid");
335
+ const sandboxError = new Error(auth.getSandboxExpiredMessage());
336
+ return Promise.reject(sandboxError);
337
+ }
254
338
  if (isRedirectToAuth) {
255
339
  logger.debug("[AUTH] Received redirect to auth endpoint - token likely expired");
256
340
  } else {
@@ -422,82 +506,6 @@ import { createHash, randomBytes } from "crypto";
422
506
  import { createServer } from "http";
423
507
  import open from "open";
424
508
  import axios3, { AxiosError as AxiosError2 } from "axios";
425
-
426
- // src/build-config.ts
427
- var BUILD_ENV = true ? "production" : process.env.NODE_ENV === "test" ? "development" : "production";
428
- var CLI_VERSION = true ? "0.2.25" : "0.0.0-test";
429
- var PRODUCTION_CONFIG = {
430
- apiUrl: "https://app.braingrid.ai",
431
- workosAuthUrl: "https://auth.braingrid.ai",
432
- workosClientId: "client_01K6H010C9K69HSDPM9CQM85S7"
433
- };
434
- var DEVELOPMENT_CONFIG = {
435
- apiUrl: "https://app.dev.braingrid.ai",
436
- workosAuthUrl: "https://balanced-celebration-78-staging.authkit.app",
437
- workosClientId: "client_01K6H04GF21T4JXNS3JDQM3YNE"
438
- };
439
-
440
- // src/utils/config.ts
441
- function getConfig() {
442
- const baseConfig = BUILD_ENV === "production" ? PRODUCTION_CONFIG : DEVELOPMENT_CONFIG;
443
- let apiUrl = baseConfig.apiUrl;
444
- if (BUILD_ENV === "development") {
445
- if (process.env.NODE_ENV === "local" || process.env.NODE_ENV === "test") {
446
- apiUrl = "http://localhost:3377";
447
- } else if (process.env.NODE_ENV === "development") {
448
- apiUrl = DEVELOPMENT_CONFIG.apiUrl;
449
- }
450
- }
451
- const getWorkOSAuthUrl = () => {
452
- if (process.env.WORKOS_AUTH_URL) {
453
- return process.env.WORKOS_AUTH_URL;
454
- }
455
- if (BUILD_ENV === "production") {
456
- return PRODUCTION_CONFIG.workosAuthUrl;
457
- }
458
- const env = process.env.NODE_ENV || "development";
459
- if (env === "local" || env === "test" || env === "development" || env === "staging") {
460
- return DEVELOPMENT_CONFIG.workosAuthUrl;
461
- }
462
- return PRODUCTION_CONFIG.workosAuthUrl;
463
- };
464
- const getOAuthClientId = () => {
465
- if (process.env.WORKOS_CLIENT_ID) {
466
- return process.env.WORKOS_CLIENT_ID;
467
- }
468
- if (BUILD_ENV === "production") {
469
- return PRODUCTION_CONFIG.workosClientId;
470
- }
471
- const env = process.env.NODE_ENV || "development";
472
- if (env === "local" || env === "test" || env === "development" || env === "staging") {
473
- return DEVELOPMENT_CONFIG.workosClientId;
474
- }
475
- return PRODUCTION_CONFIG.workosClientId;
476
- };
477
- const getWebAppUrl = () => {
478
- if (process.env.BRAINGRID_WEB_URL) {
479
- return process.env.BRAINGRID_WEB_URL;
480
- }
481
- if (BUILD_ENV === "production") {
482
- return PRODUCTION_CONFIG.apiUrl;
483
- }
484
- const env = process.env.NODE_ENV || "development";
485
- if (env === "local" || env === "test") {
486
- return "http://localhost:3377";
487
- }
488
- return DEVELOPMENT_CONFIG.apiUrl;
489
- };
490
- return {
491
- apiUrl: process.env.BRAINGRID_API_URL || apiUrl,
492
- organizationId: process.env.BRAINGRID_ORG_ID,
493
- clientId: process.env.BRAINGRID_CLIENT_ID || "braingrid-cli",
494
- oauthClientId: getOAuthClientId(),
495
- getWorkOSAuthUrl,
496
- getWebAppUrl
497
- };
498
- }
499
-
500
- // src/services/oauth2-auth.ts
501
509
  var logger2 = getLogger();
502
510
  var OAuth2Handler = class {
503
511
  /**
@@ -1000,6 +1008,7 @@ var KEYCHAIN_SERVICE = "braingrid-cli";
1000
1008
  var KEYCHAIN_ACCOUNT = "session";
1001
1009
  var GITHUB_KEYCHAIN_ACCOUNT = "github-token";
1002
1010
  var BraingridAuth = class {
1011
+ // Cached session from env token
1003
1012
  constructor(baseUrl) {
1004
1013
  this.lastValidationTime = 0;
1005
1014
  this.lastValidationResult = false;
@@ -1011,11 +1020,103 @@ var BraingridAuth = class {
1011
1020
  this.oauthHandler = null;
1012
1021
  // Store refresh token separately
1013
1022
  this.logger = getLogger();
1023
+ this.envTokenSession = null;
1014
1024
  const config = getConfig();
1015
1025
  this.baseUrl = baseUrl || config.apiUrl || "https://app.braingrid.ai";
1016
1026
  }
1027
+ /**
1028
+ * Check if CLI is using BRAINGRID_API_TOKEN environment variable
1029
+ * This is used in sandbox environments where tokens are injected
1030
+ */
1031
+ isUsingEnvToken() {
1032
+ return Boolean(BRAINGRID_API_TOKEN);
1033
+ }
1034
+ /**
1035
+ * Get the env token value (for use in axios interceptor)
1036
+ */
1037
+ getEnvToken() {
1038
+ return BRAINGRID_API_TOKEN;
1039
+ }
1040
+ /**
1041
+ * Fetch user profile from server using the env token
1042
+ * This is used to populate session data when using BRAINGRID_API_TOKEN
1043
+ */
1044
+ async fetchProfileFromServer() {
1045
+ if (!BRAINGRID_API_TOKEN) {
1046
+ return null;
1047
+ }
1048
+ try {
1049
+ this.logger.debug("[AUTH] Fetching profile using BRAINGRID_API_TOKEN");
1050
+ const response = await axiosWithRetry(
1051
+ {
1052
+ url: `${this.baseUrl}/api/v1/profile`,
1053
+ method: "POST",
1054
+ headers: {
1055
+ "Content-Type": "application/json",
1056
+ Authorization: `Bearer ${BRAINGRID_API_TOKEN}`
1057
+ },
1058
+ maxRedirects: 0,
1059
+ validateStatus: (status) => status < 500
1060
+ },
1061
+ {
1062
+ maxRetries: 2,
1063
+ initialDelay: 500
1064
+ }
1065
+ );
1066
+ if (response.status !== 200) {
1067
+ this.logger.warn(`[AUTH] Profile fetch failed with status ${response.status}`);
1068
+ return null;
1069
+ }
1070
+ const profileData = response.data;
1071
+ if (profileData.error || !profileData.user || !profileData.organization) {
1072
+ this.logger.warn("[AUTH] Profile response missing user or organization data");
1073
+ return null;
1074
+ }
1075
+ const user = {
1076
+ object: "user",
1077
+ id: profileData.user.id,
1078
+ email: profileData.user.email,
1079
+ emailVerified: true,
1080
+ firstName: profileData.user.firstName || "",
1081
+ lastName: profileData.user.lastName || "",
1082
+ profilePictureUrl: profileData.user.avatar || "",
1083
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1084
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1085
+ lastSignInAt: (/* @__PURE__ */ new Date()).toISOString(),
1086
+ externalId: null,
1087
+ metadata: {
1088
+ username: profileData.user.username,
1089
+ organizationId: profileData.organization.id,
1090
+ organizationName: profileData.organization.name
1091
+ }
1092
+ };
1093
+ const session = {
1094
+ user,
1095
+ sealed_session: BRAINGRID_API_TOKEN,
1096
+ organization_id: profileData.organization.id,
1097
+ created_at: /* @__PURE__ */ new Date(),
1098
+ updated_at: /* @__PURE__ */ new Date(),
1099
+ login_time: /* @__PURE__ */ new Date()
1100
+ };
1101
+ this.envTokenSession = session;
1102
+ this.logger.debug("[AUTH] Successfully fetched profile from env token");
1103
+ return session;
1104
+ } catch (error) {
1105
+ this.logger.error("[AUTH] Error fetching profile with env token:", { error });
1106
+ return null;
1107
+ }
1108
+ }
1017
1109
  async isAuthenticated(forceValidation = false) {
1018
1110
  try {
1111
+ if (BRAINGRID_API_TOKEN) {
1112
+ this.logger.debug("[AUTH] Using BRAINGRID_API_TOKEN (sandbox mode)");
1113
+ if (isJWTExpired(BRAINGRID_API_TOKEN)) {
1114
+ this.logger.warn("[AUTH] Sandbox session expired");
1115
+ return false;
1116
+ }
1117
+ const session2 = await this.getStoredSession();
1118
+ return session2 !== null;
1119
+ }
1019
1120
  const session = await this.getStoredSession();
1020
1121
  if (!session) {
1021
1122
  this.logger.debug("[AUTH] No stored session found");
@@ -1149,6 +1250,12 @@ var BraingridAuth = class {
1149
1250
  }
1150
1251
  }
1151
1252
  async getStoredSession() {
1253
+ if (BRAINGRID_API_TOKEN) {
1254
+ if (this.envTokenSession) {
1255
+ return this.envTokenSession;
1256
+ }
1257
+ return await this.fetchProfileFromServer();
1258
+ }
1152
1259
  try {
1153
1260
  const sessionData = await credentialStore.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
1154
1261
  if (!sessionData) return null;
@@ -1177,12 +1284,23 @@ var BraingridAuth = class {
1177
1284
  this.loginTime = nowMs;
1178
1285
  }
1179
1286
  async clearSession() {
1287
+ if (BRAINGRID_API_TOKEN) {
1288
+ this.logger.debug("[AUTH] Session managed by sandbox environment - clear is no-op");
1289
+ this.envTokenSession = null;
1290
+ return;
1291
+ }
1180
1292
  await credentialStore.deletePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
1181
1293
  await credentialStore.deletePassword(KEYCHAIN_SERVICE, "refresh-token");
1182
1294
  this.refreshTokenValue = void 0;
1183
1295
  this.lastValidationTime = 0;
1184
1296
  this.lastValidationResult = false;
1185
1297
  }
1298
+ /**
1299
+ * Get sandbox-specific error message for expired/invalid tokens
1300
+ */
1301
+ getSandboxExpiredMessage() {
1302
+ return "Sandbox session expired. Your sandbox environment has a 5-hour limit.\nPlease create a new sandbox to continue.";
1303
+ }
1186
1304
  async handleAuthenticationError() {
1187
1305
  this.lastValidationTime = 0;
1188
1306
  this.lastValidationResult = false;
@@ -2626,6 +2744,11 @@ function formatRequirementOutput(requirement2, options) {
2626
2744
  }
2627
2745
  message += `${chalk5.bold("Status:")} ${requirement2.status}
2628
2746
  `;
2747
+ if (requirement2.tags && requirement2.tags.length > 0) {
2748
+ const tagNames = requirement2.tags.map((tag) => tag.name).join(", ");
2749
+ message += `${chalk5.bold("Tags:")} ${tagNames}
2750
+ `;
2751
+ }
2629
2752
  if (requirement2.assignee) {
2630
2753
  const assigneeName = requirement2.assignee.first_name || requirement2.assignee.last_name ? `${requirement2.assignee.first_name || ""} ${requirement2.assignee.last_name || ""}`.trim() : requirement2.assignee.email;
2631
2754
  message += `${chalk5.bold("Assigned to:")} ${assigneeName} (${requirement2.assignee.email})
@@ -2772,15 +2895,16 @@ function formatRequirementListMarkdown(requirements, pagination) {
2772
2895
  md += "_No requirements found._\n";
2773
2896
  return md;
2774
2897
  }
2775
- md += "| Short ID | Status | Name | Branch | Progress |\n";
2776
- md += "|----------|--------|------|--------|----------|\n";
2898
+ md += "| Short ID | Status | Name | Branch | Tags | Progress |\n";
2899
+ md += "|----------|--------|------|--------|------|----------|\n";
2777
2900
  for (const req of requirements) {
2778
2901
  const shortId = req.short_id || req.id.slice(0, 11);
2779
2902
  const status = req.status;
2780
2903
  const name = req.name;
2781
2904
  const branch = req.branch || "N/A";
2905
+ const tags = req.tags && req.tags.length > 0 ? req.tags.map((t) => t.name).join(", ") : "-";
2782
2906
  const progress = req.task_progress ? `${req.task_progress.progress_percentage}%` : "N/A";
2783
- md += `| ${shortId} | ${status} | ${name} | ${branch} | ${progress} |
2907
+ md += `| ${shortId} | ${status} | ${name} | ${branch} | ${tags} | ${progress} |
2784
2908
  `;
2785
2909
  }
2786
2910
  if (pagination) {
@@ -2853,6 +2977,15 @@ function formatRequirementListXml(requirements, pagination) {
2853
2977
  `;
2854
2978
  xml += " </assignee>\n";
2855
2979
  }
2980
+ if (req.tags && req.tags.length > 0) {
2981
+ xml += ` <tags count="${req.tags.length}">
2982
+ `;
2983
+ for (const tag of req.tags) {
2984
+ xml += ` <tag>${escapeXml(tag.name)}</tag>
2985
+ `;
2986
+ }
2987
+ xml += " </tags>\n";
2988
+ }
2856
2989
  xml += ` <created_at>${escapeXml(req.created_at)}</created_at>
2857
2990
  `;
2858
2991
  xml += ` <updated_at>${escapeXml(req.updated_at)}</updated_at>
@@ -2969,6 +3102,12 @@ function formatRequirementBuildMarkdown(requirement2, options) {
2969
3102
  md += `**Status:** ${requirement2.status}
2970
3103
 
2971
3104
  `;
3105
+ if (requirement2.tags && requirement2.tags.length > 0) {
3106
+ const tagNames = requirement2.tags.map((tag) => tag.name).join(", ");
3107
+ md += `**Tags:** ${tagNames}
3108
+
3109
+ `;
3110
+ }
2972
3111
  if (requirement2.assignee) {
2973
3112
  const assigneeName = requirement2.assignee.first_name || requirement2.assignee.last_name ? `${requirement2.assignee.first_name || ""} ${requirement2.assignee.last_name || ""}`.trim() : requirement2.assignee.email;
2974
3113
  md += `**Assigned to:** ${assigneeName} (${requirement2.assignee.email})
@@ -3105,6 +3244,15 @@ function formatRequirementBuildXml(requirement2) {
3105
3244
  xml += ` <branch>${escapeXml(requirement2.branch)}</branch>
3106
3245
  `;
3107
3246
  }
3247
+ if (requirement2.tags && requirement2.tags.length > 0) {
3248
+ xml += ` <tags count="${requirement2.tags.length}">
3249
+ `;
3250
+ for (const tag of requirement2.tags) {
3251
+ xml += ` <tag>${escapeXml(tag.name)}</tag>
3252
+ `;
3253
+ }
3254
+ xml += " </tags>\n";
3255
+ }
3108
3256
  if (requirement2.assignee) {
3109
3257
  xml += " <assignee>\n";
3110
3258
  xml += ` <email>${escapeXml(requirement2.assignee.email)}</email>
@@ -3858,6 +4006,22 @@ var RequirementService = class {
3858
4006
  }
3859
4007
  };
3860
4008
 
4009
+ // src/utils/tag-validation.ts
4010
+ var MAX_TAGS = 5;
4011
+ function validateTags(tagsString) {
4012
+ if (!tagsString || tagsString.trim().length === 0) {
4013
+ return { valid: true, tags: [] };
4014
+ }
4015
+ const tags = tagsString.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
4016
+ if (tags.length > MAX_TAGS) {
4017
+ return {
4018
+ valid: false,
4019
+ error: `Maximum ${MAX_TAGS} tags allowed`
4020
+ };
4021
+ }
4022
+ return { valid: true, tags };
4023
+ }
4024
+
3861
4025
  // src/handlers/requirement.handlers.ts
3862
4026
  function getServices2() {
3863
4027
  const config = getConfig();
@@ -4085,11 +4249,23 @@ async function handleRequirementCreate(opts) {
4085
4249
  };
4086
4250
  }
4087
4251
  }
4252
+ let validatedTags;
4253
+ if (opts.tags) {
4254
+ const tagResult = validateTags(opts.tags);
4255
+ if (!tagResult.valid) {
4256
+ return {
4257
+ success: false,
4258
+ message: chalk7.red(`\u274C ${tagResult.error}`)
4259
+ };
4260
+ }
4261
+ validatedTags = tagResult.tags;
4262
+ }
4088
4263
  stopSpinner = showSpinner("Creating requirement", chalk7.gray);
4089
4264
  const requirement2 = await requirementService.createProjectRequirement(projectId, {
4090
4265
  name: opts.name,
4091
4266
  content: opts.content || null,
4092
- assigned_to: opts.assignedTo || null
4267
+ assigned_to: opts.assignedTo || null,
4268
+ tags: validatedTags
4093
4269
  });
4094
4270
  stopSpinner();
4095
4271
  stopSpinner = null;
@@ -4162,9 +4338,21 @@ async function handleRequirementSpecify(opts) {
4162
4338
  message: chalk7.red("\u274C Prompt must be no more than 5000 characters long")
4163
4339
  };
4164
4340
  }
4341
+ let validatedTags;
4342
+ if (opts.tags) {
4343
+ const tagResult = validateTags(opts.tags);
4344
+ if (!tagResult.valid) {
4345
+ return {
4346
+ success: false,
4347
+ message: chalk7.red(`\u274C ${tagResult.error}`)
4348
+ };
4349
+ }
4350
+ validatedTags = tagResult.tags;
4351
+ }
4165
4352
  stopSpinner = showSpinner("Specifying requirement...");
4166
4353
  const requirement2 = await requirementService.specifyRequirement(projectId, {
4167
- prompt: opts.prompt
4354
+ prompt: opts.prompt,
4355
+ tags: validatedTags
4168
4356
  });
4169
4357
  stopSpinner();
4170
4358
  stopSpinner = null;
@@ -5317,6 +5505,14 @@ function getAuth() {
5317
5505
  async function handleLogin() {
5318
5506
  try {
5319
5507
  const auth = getAuth();
5508
+ if (BRAINGRID_API_TOKEN) {
5509
+ return {
5510
+ success: true,
5511
+ message: chalk9.blue(
5512
+ "\u2139\uFE0F Using BRAINGRID_API_TOKEN - already authenticated via sandbox environment."
5513
+ )
5514
+ };
5515
+ }
5320
5516
  console.log(chalk9.blue("\u{1F510} Starting OAuth2 authentication flow..."));
5321
5517
  console.log(chalk9.dim("Your browser will open to complete authentication.\n"));
5322
5518
  const gitUser = await getGitUser();
@@ -5350,6 +5546,12 @@ async function handleLogin() {
5350
5546
  async function handleLogout() {
5351
5547
  try {
5352
5548
  const auth = getAuth();
5549
+ if (BRAINGRID_API_TOKEN) {
5550
+ return {
5551
+ success: true,
5552
+ message: chalk9.blue("\u2139\uFE0F Session managed by sandbox environment - logout not required.")
5553
+ };
5554
+ }
5353
5555
  await auth.clearSession();
5354
5556
  return {
5355
5557
  success: true,
@@ -5367,6 +5569,12 @@ async function handleWhoami() {
5367
5569
  const auth = getAuth();
5368
5570
  const isAuthenticated = await auth.isAuthenticated();
5369
5571
  if (!isAuthenticated) {
5572
+ if (BRAINGRID_API_TOKEN) {
5573
+ return {
5574
+ success: false,
5575
+ message: chalk9.red(auth.getSandboxExpiredMessage())
5576
+ };
5577
+ }
5370
5578
  return {
5371
5579
  success: false,
5372
5580
  message: chalk9.yellow("\u26A0\uFE0F Not logged in. Run `braingrid login` to authenticate.")
@@ -5389,8 +5597,13 @@ async function handleWhoami() {
5389
5597
  `;
5390
5598
  output += `${chalk9.bold("Org ID:")} ${session.organization_id}
5391
5599
  `;
5392
- output += `${chalk9.bold("Session:")} ${new Date(session.created_at).toLocaleString()}
5600
+ if (BRAINGRID_API_TOKEN) {
5601
+ output += `${chalk9.bold("Auth:")} ${chalk9.cyan("Sandbox API Token")}
5393
5602
  `;
5603
+ } else {
5604
+ output += `${chalk9.bold("Session:")} ${new Date(session.created_at).toLocaleString()}
5605
+ `;
5606
+ }
5394
5607
  return {
5395
5608
  success: true,
5396
5609
  message: output,
@@ -7467,7 +7680,7 @@ program.command("update").description("Update BrainGrid CLI to the latest versio
7467
7680
  program.command("specify").description("Create AI-refined requirement from prompt").option(
7468
7681
  "-p, --project <id>",
7469
7682
  "project ID (auto-detects from .braingrid/project.json if not provided)"
7470
- ).requiredOption("--prompt <prompt>", "requirement description (10-5000 characters)").option("--format <format>", "output format (table, json, xml, markdown)", "table").action(async (opts) => {
7683
+ ).requiredOption("--prompt <prompt>", "requirement description (10-5000 characters)").option("-t, --tags <tags>", "comma-separated tags (max 5)").option("--format <format>", "output format (table, json, xml, markdown)", "table").action(async (opts) => {
7471
7684
  const result = await handleRequirementSpecify(opts);
7472
7685
  console.log(result.message);
7473
7686
  if (!result.success) {
@@ -7552,7 +7765,7 @@ requirement.command("show [id]").description("Show requirement details (auto-det
7552
7765
  requirement.command("create").description("Create a new requirement").option(
7553
7766
  "-p, --project <id>",
7554
7767
  "project ID (auto-detects from .braingrid/project.json if not provided)"
7555
- ).requiredOption("-n, --name <name>", "requirement name").option("-c, --content <content>", "requirement content/description").option("-a, --assigned-to <uuid>", "user UUID to assign the requirement to").action(async (opts) => {
7768
+ ).requiredOption("-n, --name <name>", "requirement name").option("-c, --content <content>", "requirement content/description").option("-a, --assigned-to <uuid>", "user UUID to assign the requirement to").option("-t, --tags <tags>", "comma-separated tags (max 5)").action(async (opts) => {
7556
7769
  const result = await handleRequirementCreate(opts);
7557
7770
  console.log(result.message);
7558
7771
  if (!result.success) {