@test-lab-ai/cli 0.2.6 → 0.2.8

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/README.md CHANGED
@@ -123,6 +123,9 @@ Cursor user rules are set in Cursor's Settings. Restart your agent to load it.
123
123
  It installs from the public [`Test-Lab-ai/skills`](https://github.com/Test-Lab-ai/skills)
124
124
  mirror, so you always get the latest version.
125
125
 
126
+ `testlab skills update` refreshes every installed copy to the latest, and the CLI
127
+ also refreshes them automatically the first time you run it after an upgrade.
128
+
126
129
  ## For AI agents
127
130
 
128
131
  Run **`testlab examples`** for the complete JSON reference, or read `AGENTS.md`
package/bin/testlab.mjs CHANGED
@@ -23,8 +23,8 @@ import { apiFetch } from "../lib/api.mjs"
23
23
  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
- import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill } from "../lib/skills.mjs"
27
- import { checkForUpdate, currentVersion } from "../lib/update-check.mjs"
26
+ import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill, installedSkillLocations } from "../lib/skills.mjs"
27
+ import { checkForUpdate, currentVersion, previousRunVersion } 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
@@ -50,12 +51,14 @@ Usage:
50
51
  resource (designed for AI agents)
51
52
  testlab skills install [--agent ...] Install the test-lab-plan skill (auto-detects
52
53
  Claude/Codex/Cursor; --agent claude|codex|cursor|all)
54
+ testlab skills update Refresh installed skills (also auto-runs after a CLI upgrade)
53
55
 
54
56
  Options:
55
57
  --key <tl_…> API key (else $TESTLAB_API_KEY or ~/.test-lab/config.json)
56
58
  --stdin Read the value (key/credential) from stdin
57
59
  --force (login) re-authenticate even if a stored key still works
58
60
  --dry-run (import) validate + print plan order without writing
61
+ --project <id|name> (import/plans) assign plans to a project, or "none" for account-level
59
62
  --version, -v Print the installed CLI version
60
63
 
61
64
  Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
@@ -75,6 +78,7 @@ function parse() {
75
78
  force: { type: "boolean" },
76
79
  global: { type: "boolean" },
77
80
  agent: { type: "string" },
81
+ project: { type: "string" },
78
82
  version: { type: "boolean", short: "v" },
79
83
  help: { type: "boolean", short: "h" },
80
84
  },
@@ -185,7 +189,8 @@ async function cmdPlansCreate(flags) {
185
189
  } else {
186
190
  errExit("provide -f <plan.json>, or both --name and --prompt")
187
191
  }
188
- const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", plan)
192
+ const projectId = plan.projectId ?? (await resolveProjectId(apiUrl, apiKey, flags))
193
+ const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", { ...plan, projectId })
189
194
  if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
190
195
  log(`✓ Created plan #${r.json.testPlan.id}: ${r.json.testPlan.name}`)
191
196
  }
@@ -231,6 +236,60 @@ async function cmdLabelsList(flags) {
231
236
  log(`\n${labels.length} label(s)`)
232
237
  }
233
238
 
239
+ // Resolve which project imported plans go into.
240
+ // --project none -> null (account-level, no project)
241
+ // --project <id|name> -> that project
242
+ // (no flag) -> 0 projects: null; 1 project: auto; many: prompt (TTY)
243
+ // or, for an agent/CI (no TTY), error listing the choices.
244
+ async function resolveProjectId(apiUrl, apiKey, flags) {
245
+ const flag = flags.project
246
+ if (flag === "none" || flag === "null") return null
247
+ const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
248
+ if (!r.ok) errExit(`could not list projects (${r.status}): ${r.json?.error || ""}`)
249
+ const projects = r.json?.projects || []
250
+ if (flag) {
251
+ const match = /^\d+$/.test(flag)
252
+ ? projects.find((p) => p.id === parseInt(flag, 10))
253
+ : projects.find((p) => p.name.toLowerCase() === flag.toLowerCase())
254
+ if (!match) {
255
+ errExit(`project "${flag}" not found. Available: ${projects.map((p) => p.name).join(", ") || "(none)"}`)
256
+ }
257
+ return match.id
258
+ }
259
+ if (projects.length === 0) return null
260
+ if (projects.length === 1) {
261
+ log(`Using project: ${projects[0].name} (#${projects[0].id})`)
262
+ return projects[0].id
263
+ }
264
+ if (!process.stdin.isTTY) {
265
+ errExit(
266
+ `You have ${projects.length} projects — pick one with --project <id|name> (or --project none):\n` +
267
+ projects.map((p) => ` ${p.id} ${p.name}`).join("\n")
268
+ )
269
+ }
270
+ log(`Which project should these plans go in?`)
271
+ projects.forEach((p, i) => log(` ${i + 1}. ${p.name} (#${p.id})`))
272
+ log(` 0. None (account-level)`)
273
+ const ans = await promptLine("Project [number]: ")
274
+ if (ans === "0") return null
275
+ const idx = parseInt(ans, 10)
276
+ if (idx >= 1 && idx <= projects.length) return projects[idx - 1].id
277
+ errExit("invalid selection")
278
+ }
279
+
280
+ async function cmdProjectsList(flags) {
281
+ const { apiKey, apiUrl } = requireAuth(flags)
282
+ const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
283
+ if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
284
+ const projects = r.json?.projects || []
285
+ if (projects.length === 0) {
286
+ log("No projects yet. Imported plans stay account-level unless you create a project in the dashboard.")
287
+ return
288
+ }
289
+ for (const p of projects) log(` #${p.id} ${p.name}`)
290
+ log(`\n${projects.length} project(s)`)
291
+ }
292
+
234
293
  async function cmdImport(flags, args) {
235
294
  const target = args[1]
236
295
  if (!target) errExit("usage: testlab import <path> [--dry-run]")
@@ -243,8 +302,10 @@ async function cmdImport(flags, args) {
243
302
  const dryRun = flags["dry-run"]
244
303
  // --dry-run validates locally and never calls the API, so it doesn't need auth.
245
304
  const { apiKey, apiUrl } = dryRun ? resolveAuth(flags) : requireAuth(flags)
305
+ // Resolve the target project (auto-pick / prompt / --project) for real imports only.
306
+ const projectId = dryRun || loaded.plans.length === 0 ? null : await resolveProjectId(apiUrl, apiKey, flags)
246
307
  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 })
308
+ const res = await runImport({ apiUrl, apiKey, projectId, ...loaded, dryRun, log })
248
309
  if (!res.ok && !res.dryRun) process.exit(1)
249
310
  }
250
311
 
@@ -332,6 +393,46 @@ function cmdSkillsList() {
332
393
  log(`\nInstall with: testlab skills install [name] [--agent ${AGENTS.join("|")}|all]`)
333
394
  }
334
395
 
396
+ async function cmdSkillsUpdate() {
397
+ let refreshed = 0
398
+ for (const name of TESTLAB_SKILLS) {
399
+ for (const loc of installedSkillLocations(name)) {
400
+ try {
401
+ const res = await installSkill(name, loc.agent, { global: loc.global })
402
+ refreshed++
403
+ log(`✓ ${name} → ${loc.agent}${loc.global ? " (global)" : ""}: ${res.dest}`)
404
+ } catch (e) {
405
+ log(` ✗ ${name} (${loc.agent}): ${e.message}`)
406
+ }
407
+ }
408
+ }
409
+ if (refreshed === 0) log("No installed skills found here. Run `testlab skills install` first.")
410
+ else log(`\nRefreshed ${refreshed} location(s). Restart your agent to load the changes.`)
411
+ }
412
+
413
+ // After a CLI upgrade, refresh any already-installed skills in place — runs once
414
+ // per version bump (best effort). Skipped in CI / when NO_UPDATE_NOTIFIER is set.
415
+ async function maybeRefreshSkillsOnUpgrade() {
416
+ if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return
417
+ const ver = currentVersion()
418
+ const prev = previousRunVersion(ver)
419
+ if (!prev || prev === ver) return
420
+ let refreshed = 0
421
+ for (const name of TESTLAB_SKILLS) {
422
+ for (const loc of installedSkillLocations(name)) {
423
+ try {
424
+ await installSkill(name, loc.agent, { global: loc.global })
425
+ refreshed++
426
+ } catch {
427
+ /* best effort — never break the actual command */
428
+ }
429
+ }
430
+ }
431
+ if (refreshed > 0) {
432
+ process.stderr.write(`↻ CLI upgraded ${prev} → ${ver}; refreshed ${refreshed} installed skill location(s).\n`)
433
+ }
434
+ }
435
+
335
436
  async function main() {
336
437
  let parsed
337
438
  try {
@@ -354,6 +455,8 @@ async function main() {
354
455
  return
355
456
  }
356
457
 
458
+ await maybeRefreshSkillsOnUpgrade()
459
+
357
460
  switch (args[0]) {
358
461
  case "login":
359
462
  return cmdLogin(flags)
@@ -363,6 +466,9 @@ async function main() {
363
466
  if (args[1] === "list") return cmdPlansList(flags)
364
467
  if (args[1] === "create") return cmdPlansCreate(flags)
365
468
  return errExit("usage: testlab plans <list|create>")
469
+ case "projects":
470
+ if (args[1] === "list") return cmdProjectsList(flags)
471
+ return errExit("usage: testlab projects list")
366
472
  case "credentials":
367
473
  if (args[1] === "set") return cmdCredentialsSet(flags, args)
368
474
  if (args[1] === "list") return cmdCredentialsList(flags)
@@ -378,8 +484,9 @@ async function main() {
378
484
  return cmdExamples()
379
485
  case "skills":
380
486
  if (args[1] === "install") return cmdSkillsInstall(flags, args)
487
+ if (args[1] === "update") return cmdSkillsUpdate()
381
488
  if (args[1] === "list") return cmdSkillsList()
382
- return errExit("usage: testlab skills <install|list> [name] [--global]")
489
+ return errExit("usage: testlab skills <install|update|list> [name] [--global]")
383
490
  case "import":
384
491
  return cmdImport(flags, args)
385
492
  default:
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/lib/skills.mjs CHANGED
@@ -51,6 +51,23 @@ export function detectAgents() {
51
51
  return [...found]
52
52
  }
53
53
 
54
+ /**
55
+ * Where skill `name` is already installed — project (cwd) and global locations
56
+ * across all agents — filtered to the paths that actually exist. Used to
57
+ * refresh installed skills (on `skills update` and after a CLI upgrade).
58
+ */
59
+ export function installedSkillLocations(name) {
60
+ const home = os.homedir()
61
+ const cwd = process.cwd()
62
+ return [
63
+ { agent: "claude", global: false, path: path.join(cwd, ".claude", "skills", name) },
64
+ { agent: "codex", global: false, path: path.join(cwd, ".agents", "skills", name) },
65
+ { agent: "cursor", global: false, path: path.join(cwd, ".cursor", "rules", `${name}.mdc`) },
66
+ { agent: "claude", global: true, path: path.join(home, ".claude", "skills", name) },
67
+ { agent: "codex", global: true, path: path.join(home, ".agents", "skills", name) },
68
+ ].filter((c) => fs.existsSync(c.path))
69
+ }
70
+
54
71
  /** Resolve the install target (base dir + format) for one agent. */
55
72
  export function agentTarget(agent, { global } = {}) {
56
73
  const home = os.homedir()
@@ -2,7 +2,7 @@
2
2
  * Best-effort "update available" notice.
3
3
  *
4
4
  * Design constraints: never block, slow, or break a command. It reads a cached
5
- * latest-version (checked against the npm registry at most once/day), prints a
5
+ * latest-version (checked against the npm registry at most every few hours), prints a
6
6
  * one-line notice to STDERR when the running version is behind, and kicks off a
7
7
  * background refresh for next time. Any failure (offline, slow/forbidden
8
8
  * registry) is swallowed. Suppressed when stderr isn't a TTY (pipes / CI /
@@ -15,7 +15,7 @@ import path from "node:path"
15
15
  const PKG = "@test-lab-ai/cli"
16
16
  const REGISTRY = "https://registry.npmjs.org/@test-lab-ai%2Fcli"
17
17
  const CACHE = path.join(os.homedir(), ".test-lab", "update-check.json")
18
- const DAY = 24 * 60 * 60 * 1000
18
+ const CHECK_TTL = 3 * 60 * 60 * 1000 // re-check npm at most every 3h
19
19
 
20
20
  export function currentVersion() {
21
21
  try {
@@ -70,16 +70,29 @@ export function checkForUpdate() {
70
70
  const notice = updateNotice(current, cache.latest)
71
71
  if (notice) process.stderr.write(notice + "\n")
72
72
 
73
- if (!cache.lastCheck || Date.now() - cache.lastCheck > DAY) {
73
+ if (!cache.lastCheck || Date.now() - cache.lastCheck > CHECK_TTL) {
74
74
  const ctrl = new AbortController()
75
75
  const timer = setTimeout(() => ctrl.abort(), 2000)
76
76
  fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/vnd.npm.install-v1+json" } })
77
77
  .then((r) => (r.ok ? r.json() : null))
78
78
  .then((j) => {
79
79
  const latest = j && j["dist-tags"] && j["dist-tags"].latest
80
- if (latest) writeCache({ lastCheck: Date.now(), latest })
80
+ if (latest) writeCache({ ...cache, lastCheck: Date.now(), latest })
81
81
  })
82
82
  .catch(() => {})
83
83
  .finally(() => clearTimeout(timer))
84
84
  }
85
85
  }
86
+
87
+ /**
88
+ * Record the running CLI version and return the version seen on the PREVIOUS
89
+ * run (null on first run). Lets the CLI notice "I was just upgraded" so it can
90
+ * refresh installed skills. Merges into the shared cache so it doesn't clobber
91
+ * the update-check state.
92
+ */
93
+ export function previousRunVersion(current) {
94
+ const cache = readCache()
95
+ const prev = cache.cliVersion || null
96
+ if (prev !== current) writeCache({ ...cache, cliVersion: current })
97
+ return prev
98
+ }
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.8",
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": {