@test-lab-ai/cli 0.2.6 → 0.2.7

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/AGENTS.md CHANGED
@@ -33,7 +33,9 @@ npx @test-lab-ai/cli --help
33
33
  - If the user's repo has a test-lab plan skill/format, prefer it.
34
34
  3. **Write** the plans to a JSON file (or a directory of `*.json`).
35
35
  4. **Run** `testlab import <path>`. Use `--dry-run` first to show the user the
36
- creation order without writing anything.
36
+ creation order without writing anything. To file the plans under a project,
37
+ run `testlab projects list` and pass `--project <id|name>` (auto-picked if
38
+ there is only one; omit for account-level).
37
39
 
38
40
  Authentication: the user runs `testlab login` once (browser), or you set
39
41
  `TESTLAB_API_KEY` in the environment for a fully headless run.
package/bin/testlab.mjs CHANGED
@@ -41,6 +41,7 @@ Usage:
41
41
  (credentials, labels, fixtures, plans)
42
42
  testlab plans list List your test plans
43
43
  testlab plans create -f plan.json Create one plan from JSON
44
+ testlab projects list List your projects
44
45
  testlab credentials set <key> --value <value> Set a credential ({{credentials.<key>}})
45
46
  testlab credentials list List credential keys (values never shown)
46
47
  testlab labels list List your labels
@@ -56,6 +57,7 @@ Options:
56
57
  --stdin Read the value (key/credential) from stdin
57
58
  --force (login) re-authenticate even if a stored key still works
58
59
  --dry-run (import) validate + print plan order without writing
60
+ --project <id|name> (import/plans) assign plans to a project, or "none" for account-level
59
61
  --version, -v Print the installed CLI version
60
62
 
61
63
  Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
@@ -75,6 +77,7 @@ function parse() {
75
77
  force: { type: "boolean" },
76
78
  global: { type: "boolean" },
77
79
  agent: { type: "string" },
80
+ project: { type: "string" },
78
81
  version: { type: "boolean", short: "v" },
79
82
  help: { type: "boolean", short: "h" },
80
83
  },
@@ -185,7 +188,8 @@ async function cmdPlansCreate(flags) {
185
188
  } else {
186
189
  errExit("provide -f <plan.json>, or both --name and --prompt")
187
190
  }
188
- const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", plan)
191
+ const projectId = plan.projectId ?? (await resolveProjectId(apiUrl, apiKey, flags))
192
+ const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", { ...plan, projectId })
189
193
  if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
190
194
  log(`✓ Created plan #${r.json.testPlan.id}: ${r.json.testPlan.name}`)
191
195
  }
@@ -231,6 +235,60 @@ async function cmdLabelsList(flags) {
231
235
  log(`\n${labels.length} label(s)`)
232
236
  }
233
237
 
238
+ // Resolve which project imported plans go into.
239
+ // --project none -> null (account-level, no project)
240
+ // --project <id|name> -> that project
241
+ // (no flag) -> 0 projects: null; 1 project: auto; many: prompt (TTY)
242
+ // or, for an agent/CI (no TTY), error listing the choices.
243
+ async function resolveProjectId(apiUrl, apiKey, flags) {
244
+ const flag = flags.project
245
+ if (flag === "none" || flag === "null") return null
246
+ const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
247
+ if (!r.ok) errExit(`could not list projects (${r.status}): ${r.json?.error || ""}`)
248
+ const projects = r.json?.projects || []
249
+ if (flag) {
250
+ const match = /^\d+$/.test(flag)
251
+ ? projects.find((p) => p.id === parseInt(flag, 10))
252
+ : projects.find((p) => p.name.toLowerCase() === flag.toLowerCase())
253
+ if (!match) {
254
+ errExit(`project "${flag}" not found. Available: ${projects.map((p) => p.name).join(", ") || "(none)"}`)
255
+ }
256
+ return match.id
257
+ }
258
+ if (projects.length === 0) return null
259
+ if (projects.length === 1) {
260
+ log(`Using project: ${projects[0].name} (#${projects[0].id})`)
261
+ return projects[0].id
262
+ }
263
+ if (!process.stdin.isTTY) {
264
+ errExit(
265
+ `You have ${projects.length} projects — pick one with --project <id|name> (or --project none):\n` +
266
+ projects.map((p) => ` ${p.id} ${p.name}`).join("\n")
267
+ )
268
+ }
269
+ log(`Which project should these plans go in?`)
270
+ projects.forEach((p, i) => log(` ${i + 1}. ${p.name} (#${p.id})`))
271
+ log(` 0. None (account-level)`)
272
+ const ans = await promptLine("Project [number]: ")
273
+ if (ans === "0") return null
274
+ const idx = parseInt(ans, 10)
275
+ if (idx >= 1 && idx <= projects.length) return projects[idx - 1].id
276
+ errExit("invalid selection")
277
+ }
278
+
279
+ async function cmdProjectsList(flags) {
280
+ const { apiKey, apiUrl } = requireAuth(flags)
281
+ const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
282
+ if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
283
+ const projects = r.json?.projects || []
284
+ if (projects.length === 0) {
285
+ log("No projects yet. Imported plans stay account-level unless you create a project in the dashboard.")
286
+ return
287
+ }
288
+ for (const p of projects) log(` #${p.id} ${p.name}`)
289
+ log(`\n${projects.length} project(s)`)
290
+ }
291
+
234
292
  async function cmdImport(flags, args) {
235
293
  const target = args[1]
236
294
  if (!target) errExit("usage: testlab import <path> [--dry-run]")
@@ -243,8 +301,10 @@ async function cmdImport(flags, args) {
243
301
  const dryRun = flags["dry-run"]
244
302
  // --dry-run validates locally and never calls the API, so it doesn't need auth.
245
303
  const { apiKey, apiUrl } = dryRun ? resolveAuth(flags) : requireAuth(flags)
304
+ // Resolve the target project (auto-pick / prompt / --project) for real imports only.
305
+ const projectId = dryRun || loaded.plans.length === 0 ? null : await resolveProjectId(apiUrl, apiKey, flags)
246
306
  log(`Importing from ${target}: ${loaded.plans.length} plan(s), ${loaded.credentials.length} credential(s), ${loaded.labels.length} label(s), ${loaded.fixtures.length} fixture(s)`)
247
- const res = await runImport({ apiUrl, apiKey, ...loaded, dryRun, log })
307
+ const res = await runImport({ apiUrl, apiKey, projectId, ...loaded, dryRun, log })
248
308
  if (!res.ok && !res.dryRun) process.exit(1)
249
309
  }
250
310
 
@@ -363,6 +423,9 @@ async function main() {
363
423
  if (args[1] === "list") return cmdPlansList(flags)
364
424
  if (args[1] === "create") return cmdPlansCreate(flags)
365
425
  return errExit("usage: testlab plans <list|create>")
426
+ case "projects":
427
+ if (args[1] === "list") return cmdProjectsList(flags)
428
+ return errExit("usage: testlab projects list")
366
429
  case "credentials":
367
430
  if (args[1] === "set") return cmdCredentialsSet(flags, args)
368
431
  if (args[1] === "list") return cmdCredentialsList(flags)
package/lib/examples.mjs CHANGED
@@ -71,6 +71,7 @@ JSON (under "plans"):
71
71
  "agentType": "functional", // functional|accessibility|uiux|exploratory|performance|security
72
72
  "devices": ["Desktop Chrome"], // or ["iPhone 15 Pro"]
73
73
  "labels": ["smoke", "auth"],
74
+ "projectId": 12, // optional: assign to a project (else account-level)
74
75
  "failOnPreStepFailure": true
75
76
  }
76
77
  Pre-steps (run another plan first, sharing browser state):
@@ -80,6 +81,7 @@ Pre-steps (run another plan first, sharing browser state):
80
81
  { "testPlanId": 42 } // an existing plan by id
81
82
  ]
82
83
  Rules: name <=200 chars; prompt <=32KB; <=25 labels; <=25 preSteps.
84
+ Projects: omit projectId to import account-level. Run "testlab projects list" for ids; "testlab import --project <id|name>" (or --project none) sets it for the whole import, and is auto-picked when you have exactly one.
83
85
 
84
86
  ═══════════════════════════════════════════════════════════════════════════
85
87
  IMPORT BUNDLE — create everything at once: testlab import ./bundle.json
package/lib/import.mjs CHANGED
@@ -90,6 +90,7 @@ export async function runImport({
90
90
  labels = [],
91
91
  fixtures = [],
92
92
  plans = [],
93
+ projectId = null,
93
94
  dryRun = false,
94
95
  log = console.log,
95
96
  }) {
@@ -187,6 +188,7 @@ export async function runImport({
187
188
  cookies: plan.cookies,
188
189
  headers: plan.headers,
189
190
  failOnPreStepFailure: plan.failOnPreStepFailure,
191
+ projectId: plan.projectId ?? projectId, // per-plan id wins, else the resolved import-wide project
190
192
  preSteps: normalizePreSteps(plan.preSteps, refToId),
191
193
  }
192
194
  const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", payload)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-lab-ai/cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Import existing test plans into test-lab.ai from the command line (or an AI agent).",
5
5
  "type": "module",
6
6
  "bin": {