@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 +17 -6
- package/bin/testlab.mjs +29 -2
- package/lib/config.mjs +1 -4
- package/lib/login.mjs +3 -2
- package/lib/skills.mjs +61 -0
- package/package.json +1 -1
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
|
-
##
|
|
124
|
+
## Under the hood
|
|
112
125
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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"] ||
|
|
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 =
|
|
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
|
|
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
|
+
}
|