@test-lab-ai/cli 0.2.5 → 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
@@ -24,7 +24,7 @@ import { loadImportFile, runImport } from "../lib/import.mjs"
24
24
  import { browserLogin } from "../lib/login.mjs"
25
25
  import { EXAMPLES_TEXT } from "../lib/examples.mjs"
26
26
  import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill } from "../lib/skills.mjs"
27
- import { checkForUpdate } from "../lib/update-check.mjs"
27
+ import { checkForUpdate, currentVersion } from "../lib/update-check.mjs"
28
28
 
29
29
  const log = (...a) => console.log(...a)
30
30
  function errExit(msg) {
@@ -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,8 @@ 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
61
+ --version, -v Print the installed CLI version
59
62
 
60
63
  Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
61
64
 
@@ -74,6 +77,8 @@ function parse() {
74
77
  force: { type: "boolean" },
75
78
  global: { type: "boolean" },
76
79
  agent: { type: "string" },
80
+ project: { type: "string" },
81
+ version: { type: "boolean", short: "v" },
77
82
  help: { type: "boolean", short: "h" },
78
83
  },
79
84
  })
@@ -183,7 +188,8 @@ async function cmdPlansCreate(flags) {
183
188
  } else {
184
189
  errExit("provide -f <plan.json>, or both --name and --prompt")
185
190
  }
186
- 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 })
187
193
  if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
188
194
  log(`✓ Created plan #${r.json.testPlan.id}: ${r.json.testPlan.name}`)
189
195
  }
@@ -229,6 +235,60 @@ async function cmdLabelsList(flags) {
229
235
  log(`\n${labels.length} label(s)`)
230
236
  }
231
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
+
232
292
  async function cmdImport(flags, args) {
233
293
  const target = args[1]
234
294
  if (!target) errExit("usage: testlab import <path> [--dry-run]")
@@ -241,8 +301,10 @@ async function cmdImport(flags, args) {
241
301
  const dryRun = flags["dry-run"]
242
302
  // --dry-run validates locally and never calls the API, so it doesn't need auth.
243
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)
244
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)`)
245
- const res = await runImport({ apiUrl, apiKey, ...loaded, dryRun, log })
307
+ const res = await runImport({ apiUrl, apiKey, projectId, ...loaded, dryRun, log })
246
308
  if (!res.ok && !res.dryRun) process.exit(1)
247
309
  }
248
310
 
@@ -342,6 +404,11 @@ async function main() {
342
404
 
343
405
  checkForUpdate() // best-effort "update available" notice (cached, non-blocking)
344
406
 
407
+ if (flags.version || args[0] === "version") {
408
+ log(currentVersion() || "unknown")
409
+ return
410
+ }
411
+
345
412
  if (flags.help || args.length === 0 || args[0] === "help") {
346
413
  log(HELP)
347
414
  return
@@ -356,6 +423,9 @@ async function main() {
356
423
  if (args[1] === "list") return cmdPlansList(flags)
357
424
  if (args[1] === "create") return cmdPlansCreate(flags)
358
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")
359
429
  case "credentials":
360
430
  if (args[1] === "set") return cmdCredentialsSet(flags, args)
361
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.5",
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": {