@test-lab-ai/cli 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -100,6 +100,19 @@ in the file doesn't matter. Re-running is safe for credentials/labels/fixtures
100
100
  - `{{data.FIXTURE.FIELD}}` — a value from a data fixture (generated test data).
101
101
  - `{{run.shortId}}` — a unique per-run id (for unique emails, names, etc.).
102
102
 
103
+ ## Install the test-lab-plan skill (Claude Code)
104
+
105
+ The companion skill that *writes* test-lab plans — so an AI agent can design a
106
+ plan, then this CLI imports it:
107
+
108
+ ```bash
109
+ testlab skills install # into ./.claude/skills (this project)
110
+ testlab skills install --global # into ~/.claude/skills (all projects)
111
+ ```
112
+
113
+ Restart Claude Code to load it. It installs from the public skills mirror, so
114
+ you always get the latest version. (Targets Claude Code's `.claude/skills/` layout.)
115
+
103
116
  ## For AI agents
104
117
 
105
118
  Run **`testlab examples`** for the complete JSON reference, or read `AGENTS.md`
@@ -108,10 +121,8 @@ convert each into the plan/fixture JSON above (explicit URL in the prompt,
108
121
  secrets as `{{credentials.X}}`, generated data as `{{data.X.Y}}`) →
109
122
  `testlab import`.
110
123
 
111
- ## Advanced
124
+ ## Under the hood
112
125
 
113
- - `--api-url <url>` (or `TESTLAB_API_URL`) targets a non-prod instance (local dev
114
- / self-hosted). Normal users never need it.
115
- - Every command is a thin wrapper over the public
116
- [Import API](https://test-lab.ai/docs/api/test-plans); an agent can call that
117
- directly instead of shelling out to the CLI.
126
+ Every command is a thin wrapper over the public
127
+ [Import API](https://test-lab.ai/docs/api/test-plans); an agent can call that
128
+ directly instead of shelling out to the CLI.
package/bin/testlab.mjs CHANGED
@@ -23,6 +23,7 @@ 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, resolveSkillsDir, installSkill } from "../lib/skills.mjs"
26
27
 
27
28
  const log = (...a) => console.log(...a)
28
29
  function errExit(msg) {
@@ -44,15 +45,15 @@ Usage:
44
45
  testlab data create -f fixture.json Create a data fixture ({{data.KEY.FIELD}})
45
46
  testlab examples Print the full JSON reference for every
46
47
  resource (designed for AI agents)
48
+ testlab skills install [--global] Install the test-lab-plan skill into Claude Code
47
49
 
48
50
  Options:
49
51
  --key <tl_…> API key (else $TESTLAB_API_KEY or ~/.test-lab/config.json)
50
52
  --stdin Read the value (key/credential) from stdin
51
53
  --force (login) re-authenticate even if a stored key still works
52
54
  --dry-run (import) validate + print plan order without writing
53
- --api-url <url> Target a non-prod instance (default https://www.test-lab.ai)
54
55
 
55
- Get a key at <api-url>/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
56
+ Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
56
57
 
57
58
  function parse() {
58
59
  return parseArgs({
@@ -67,6 +68,8 @@ function parse() {
67
68
  "dry-run": { type: "boolean" },
68
69
  stdin: { type: "boolean" },
69
70
  force: { type: "boolean" },
71
+ global: { type: "boolean" },
72
+ dir: { type: "string" },
70
73
  help: { type: "boolean", short: "h" },
71
74
  },
72
75
  })
@@ -249,6 +252,26 @@ function cmdExamples() {
249
252
  log(EXAMPLES_TEXT)
250
253
  }
251
254
 
255
+ async function cmdSkillsInstall(flags, args) {
256
+ const requested = args[2] ? [args[2]] : TESTLAB_SKILLS
257
+ const targetDir = resolveSkillsDir({ global: flags.global, dir: flags.dir })
258
+ for (const name of requested) {
259
+ try {
260
+ const res = await installSkill(name, targetDir)
261
+ log(`✓ Installed ${res.name} (${res.count} file${res.count === 1 ? "" : "s"}) → ${res.dir}`)
262
+ } catch (e) {
263
+ errExit(`failed to install ${name}: ${e.message}`)
264
+ }
265
+ }
266
+ log(`\nRestart Claude Code (start a new session) to load the skill.`)
267
+ }
268
+
269
+ function cmdSkillsList() {
270
+ log(`Available test-lab skills:`)
271
+ for (const s of TESTLAB_SKILLS) log(` ${s}`)
272
+ log(`\nInstall with: testlab skills install [name] [--global]`)
273
+ }
274
+
252
275
  async function main() {
253
276
  let parsed
254
277
  try {
@@ -282,6 +305,10 @@ async function main() {
282
305
  return errExit("usage: testlab data <list|create>")
283
306
  case "examples":
284
307
  return cmdExamples()
308
+ case "skills":
309
+ if (args[1] === "install") return cmdSkillsInstall(flags, args)
310
+ if (args[1] === "list") return cmdSkillsList()
311
+ return errExit("usage: testlab skills <install|list> [name] [--global]")
285
312
  case "import":
286
313
  return cmdImport(flags, args)
287
314
  default:
package/lib/config.mjs CHANGED
@@ -36,9 +36,6 @@ export function saveConfig(cfg) {
36
36
  export function resolveAuth(flags) {
37
37
  const cfg = loadConfig()
38
38
  const apiKey = flags.key || process.env.TESTLAB_API_KEY || cfg.apiKey || null
39
- const apiUrl = (flags["api-url"] || process.env.TESTLAB_API_URL || cfg.apiUrl || DEFAULT_API_URL).replace(
40
- /\/+$/,
41
- ""
42
- )
39
+ const apiUrl = (flags["api-url"] || cfg.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "")
43
40
  return { apiKey, apiUrl }
44
41
  }
package/lib/login.mjs CHANGED
@@ -38,7 +38,7 @@ function openBrowser(url) {
38
38
  }
39
39
  }
40
40
 
41
- export function browserLogin(apiUrl, { timeoutMs = 180000 } = {}) {
41
+ export function browserLogin(apiUrl, { timeoutMs = 600000 } = {}) {
42
42
  const state = crypto.randomBytes(16).toString("hex")
43
43
 
44
44
  return new Promise((resolve, reject) => {
@@ -80,13 +80,14 @@ export function browserLogin(apiUrl, { timeoutMs = 180000 } = {}) {
80
80
 
81
81
  const timer = setTimeout(() => {
82
82
  server.close()
83
- reject(new Error("login timed out after 3 minutes"))
83
+ reject(new Error("login timed out after 10 minutes"))
84
84
  }, timeoutMs)
85
85
 
86
86
  server.listen(0, "127.0.0.1", () => {
87
87
  const { port } = server.address()
88
88
  const authUrl = `${apiUrl}/cli/authorize?state=${state}&port=${port}`
89
89
  console.log(`Opening your browser to authorize the CLI:\n ${authUrl}\n`)
90
+ console.log(`Waiting for you to approve in the browser (up to 10 minutes)…\n`)
90
91
  openBrowser(authUrl)
91
92
  })
92
93
  })
package/lib/skills.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Install test-lab skills (e.g. test-lab-plan) into a local agent's skills
3
+ * directory by fetching them from the public Test-Lab-ai/skills mirror.
4
+ *
5
+ * Zero-dep: global fetch + node:fs. Targets Claude Code's
6
+ * .claude/skills/<name>/ layout — project dir by default, ~/.claude with
7
+ * --global, or an explicit --dir. The mirror is the single source of truth,
8
+ * so this always installs the latest published skill.
9
+ */
10
+ import fs from "node:fs"
11
+ import os from "node:os"
12
+ import path from "node:path"
13
+
14
+ const MIRROR = "Test-Lab-ai/skills"
15
+ const MIRROR_BRANCH = "main"
16
+
17
+ // Skills published by test-lab. Add new skill directory names here.
18
+ export const TESTLAB_SKILLS = ["test-lab-plan"]
19
+
20
+ export function resolveSkillsDir({ global, dir } = {}) {
21
+ if (dir) return path.resolve(dir)
22
+ if (global) return path.join(os.homedir(), ".claude", "skills")
23
+ return path.join(process.cwd(), ".claude", "skills")
24
+ }
25
+
26
+ const UA = { "User-Agent": "test-lab-cli" }
27
+
28
+ // List the blob paths under skills/<name>/ in the mirror's tree.
29
+ async function listSkillFiles(name) {
30
+ const r = await fetch(
31
+ `https://api.github.com/repos/${MIRROR}/git/trees/${MIRROR_BRANCH}?recursive=1`,
32
+ { headers: { ...UA, Accept: "application/vnd.github+json" } }
33
+ )
34
+ if (!r.ok) throw new Error(`could not list ${MIRROR} (${r.status})`)
35
+ const tree = await r.json()
36
+ const prefix = `skills/${name}/`
37
+ return (tree.tree || [])
38
+ .filter((e) => e.type === "blob" && typeof e.path === "string" && e.path.startsWith(prefix))
39
+ .map((e) => e.path)
40
+ }
41
+
42
+ /** Fetch every file of one skill from the mirror and write it under targetDir/<name>/. */
43
+ export async function installSkill(name, targetDir) {
44
+ const files = await listSkillFiles(name)
45
+ if (files.length === 0) throw new Error(`skill "${name}" not found in ${MIRROR}`)
46
+ const prefix = `skills/${name}/`
47
+ let count = 0
48
+ for (const p of files) {
49
+ const rel = p.slice(prefix.length)
50
+ const raw = await fetch(`https://raw.githubusercontent.com/${MIRROR}/${MIRROR_BRANCH}/${p}`, {
51
+ headers: UA,
52
+ })
53
+ if (!raw.ok) throw new Error(`could not fetch ${p} (${raw.status})`)
54
+ const body = await raw.text()
55
+ const dest = path.join(targetDir, name, rel)
56
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
57
+ fs.writeFileSync(dest, body)
58
+ count++
59
+ }
60
+ return { name, count, dir: path.join(targetDir, name) }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-lab-ai/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {