@isummation/kasauti-cli 0.2.0
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 +12 -0
- package/kasauti.mjs +208 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @isummation/kasauti-cli
|
|
2
|
+
|
|
3
|
+
Command line for [Kasauti](https://gitlab.isummation.com/isummation-product/kasauti) test management: explore, author, run, and report and fix defects. Used by people, CI and AI agents. Output is Markdown; add `--json` for scripts. Node 20+, no dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @isummation/kasauti-cli # needs access to the @isummation npm org
|
|
7
|
+
kasauti login https://<kasauti-api> --token <token>
|
|
8
|
+
kasauti map SHOP
|
|
9
|
+
kasauti --help
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`KASAUTI_URL` and `KASAUTI_TOKEN` override the saved login (that is how CI uses it). Full guide: `docs/using-kasauti-in-your-project.md` in the Kasauti repository.
|
package/kasauti.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Kasauti CLI: a thin client over the REST API. Zero dependencies (Node >= 20).
|
|
3
|
+
// Output is Markdown by default (easy for humans and LLMs); pass --json for JSON.
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { basename, join, extname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const CONFIG = process.env.KASAUTI_CONFIG ?? join(homedir(), '.kasauti.json');
|
|
10
|
+
const HELP = `kasauti <command> [args] [--json]
|
|
11
|
+
|
|
12
|
+
Setup
|
|
13
|
+
login <url> --token T save URL + a personal token (Settings → API tokens) to ~/.kasauti.json
|
|
14
|
+
token <name> [--agent NAME] create a personal (or agent) API token
|
|
15
|
+
whoami | projects | mcp-config
|
|
16
|
+
|
|
17
|
+
Explore
|
|
18
|
+
map <PROJECT> area tree with coverage and open defects
|
|
19
|
+
get <KEY> any entity: DEMO-12, DEMO-A3, DEMO-SC1, DEMO-R5, DEMO-D7
|
|
20
|
+
search <PROJECT> [text] [--type case|scenario|area|defect|run] [--status S] [--area KEY] [--tag T]
|
|
21
|
+
activity <PROJECT>
|
|
22
|
+
|
|
23
|
+
Author (idempotent: same --ref updates)
|
|
24
|
+
area <PROJECT> --title T [--route /path] [--kind screen|endpoint|module] [--parent KEY] [--notes N] [--ref R] [--key KEY]
|
|
25
|
+
scenario <PROJECT> --title T [--area KEY] [--narrative N] [--ref R] [--key KEY]
|
|
26
|
+
cases <PROJECT> <file.json|-> JSON array of cases (or {"cases": [...]})
|
|
27
|
+
edit <KEY> '<json>' partial update of any entity
|
|
28
|
+
approve <KEY...> promote draft cases to actual (humans only)
|
|
29
|
+
|
|
30
|
+
Execute
|
|
31
|
+
run start <PROJECT> --title T [--plan KEY] [--area KEY] [--scenario KEY] [--cases K1,K2] [--env E]
|
|
32
|
+
run next|complete|abort <RUN>
|
|
33
|
+
result <RUN> <CASE> <passed|failed|blocked|skipped|invalid> [-m comment] [--attach FILE]... [--duration MS]
|
|
34
|
+
|
|
35
|
+
Defects
|
|
36
|
+
defects <PROJECT> [--status unresolved|open|in_progress|fixed|verified|invalid] [--mine]
|
|
37
|
+
defect report <PROJECT> --title T [--case KEY --run KEY] [--area KEY] [--url U] [--actual A] [--expected E] [--steps S] [--severity S] [--attach FILE]...
|
|
38
|
+
defect claim|release <KEY>
|
|
39
|
+
defect resolve <KEY> -m NOTES [--commit SHA] [--pr URL]
|
|
40
|
+
defect verify|reopen|invalid <KEY> [-m COMMENT]
|
|
41
|
+
verification <PROJECT> fixed defects awaiting a tester
|
|
42
|
+
|
|
43
|
+
Other
|
|
44
|
+
comment <KEY> -m TEXT | attach <KEY> <FILE> [--kind screenshot]
|
|
45
|
+
import openapi|csv <PROJECT> <FILE> | export csv <PROJECT>
|
|
46
|
+
api <METHOD> <PATH> [JSON] raw call
|
|
47
|
+
|
|
48
|
+
Env: KASAUTI_URL, KASAUTI_TOKEN override the config file.`;
|
|
49
|
+
|
|
50
|
+
const { values: o, positionals: args } = parseArgs({
|
|
51
|
+
allowPositionals: true,
|
|
52
|
+
options: {
|
|
53
|
+
json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
|
|
54
|
+
token: { type: 'string' }, agent: { type: 'string' },
|
|
55
|
+
title: { type: 'string' }, route: { type: 'string' }, kind: { type: 'string' }, parent: { type: 'string' }, notes: { type: 'string' },
|
|
56
|
+
ref: { type: 'string' }, key: { type: 'string' }, area: { type: 'string' }, scenario: { type: 'string' }, narrative: { type: 'string' },
|
|
57
|
+
type: { type: 'string' }, status: { type: 'string' }, tag: { type: 'string' }, plan: { type: 'string' }, cases: { type: 'string' },
|
|
58
|
+
env: { type: 'string' }, message: { type: 'string', short: 'm' }, attach: { type: 'string', multiple: true }, duration: { type: 'string' },
|
|
59
|
+
case: { type: 'string' }, run: { type: 'string' }, url: { type: 'string' }, actual: { type: 'string' }, expected: { type: 'string' },
|
|
60
|
+
steps: { type: 'string' }, severity: { type: 'string' }, commit: { type: 'string' }, pr: { type: 'string' }, mine: { type: 'boolean' }, force: { type: 'boolean' },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const config = () => {
|
|
65
|
+
const f = existsSync(CONFIG) ? JSON.parse(readFileSync(CONFIG, 'utf8')) : {};
|
|
66
|
+
return { url: (process.env.KASAUTI_URL ?? f.url ?? 'http://localhost:3001').replace(/\/$/, ''), token: process.env.KASAUTI_TOKEN ?? f.token };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const die = (msg) => {
|
|
70
|
+
console.error(msg);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
async function api(method, path, body, { md = !o.json, raw = false } = {}) {
|
|
75
|
+
const { url, token } = config();
|
|
76
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
77
|
+
const res = await fetch(`${url}${path}${md ? `${sep}format=md` : ''}`, {
|
|
78
|
+
method,
|
|
79
|
+
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
80
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
81
|
+
}).catch((e) => die(`Cannot reach ${url}: ${e.message}`));
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
let msg = text;
|
|
85
|
+
try {
|
|
86
|
+
const j = JSON.parse(text);
|
|
87
|
+
msg = [j.message].flat().join('\n');
|
|
88
|
+
} catch {}
|
|
89
|
+
die(`Error ${res.status}: ${msg}`);
|
|
90
|
+
}
|
|
91
|
+
if (raw) return text;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const print = (x) => console.log(typeof x === 'string' ? x.trimEnd() : JSON.stringify(x, null, 2));
|
|
100
|
+
const need = (v, what) => v ?? die(`Missing ${what}. Run "kasauti --help".`);
|
|
101
|
+
const file = (path, kind) => ({
|
|
102
|
+
kind: kind ?? (/\.(png|jpe?g|gif|webp)$/i.test(path) ? 'screenshot' : /\.zip$/i.test(path) ? 'trace' : 'file'),
|
|
103
|
+
filename: basename(path),
|
|
104
|
+
mime: { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.json': 'application/json', '.txt': 'text/plain', '.log': 'text/plain', '.html': 'text/html', '.zip': 'application/zip', '.har': 'application/json' }[extname(path).toLowerCase()] ?? 'application/octet-stream',
|
|
105
|
+
base64: readFileSync(path).toString('base64'),
|
|
106
|
+
});
|
|
107
|
+
const readInput = (f) => readFileSync(f === '-' ? 0 : f, 'utf8');
|
|
108
|
+
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
109
|
+
|
|
110
|
+
const [cmd, sub, ...rest] = args;
|
|
111
|
+
if (o.help || !cmd) print(HELP), process.exit(0);
|
|
112
|
+
|
|
113
|
+
switch (cmd) {
|
|
114
|
+
case 'login': {
|
|
115
|
+
const url = need(sub, '<url>').replace(/\/$/, '');
|
|
116
|
+
process.env.KASAUTI_URL = url;
|
|
117
|
+
// Sign-in is Microsoft-only, so the CLI uses a token created in the web app instead of a password.
|
|
118
|
+
process.env.KASAUTI_TOKEN = need(o.token, '--token');
|
|
119
|
+
const me = await api('GET', '/me', undefined, { md: false });
|
|
120
|
+
writeFileSync(CONFIG, JSON.stringify({ url, token: o.token }, null, 2), { mode: 0o600 });
|
|
121
|
+
print(`Logged in as ${me.name} <${me.email}> (${me.role}). Saved to ${CONFIG}`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'token': {
|
|
125
|
+
const r = await api('POST', '/tokens', clean({ name: need(sub, '<name>'), kind: o.agent ? 'agent' : 'user', agentName: o.agent }), { md: false });
|
|
126
|
+
print(o.json ? r : `Token (shown once): ${r.token}\nUse: export KASAUTI_TOKEN=${r.token}`);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'whoami': print(await api('GET', '/me', undefined, { md: false })); break;
|
|
130
|
+
case 'projects': print(await api('GET', '/projects')); break;
|
|
131
|
+
case 'mcp-config': {
|
|
132
|
+
const { url } = config();
|
|
133
|
+
print(`claude mcp add --transport http kasauti ${url}/mcp --header "Authorization: Bearer <agent token>"\n\nCreate an agent token with: kasauti token claude-code --agent claude-code`);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'map': print(await api('GET', `/projects/${need(sub, '<PROJECT>')}/map`)); break;
|
|
137
|
+
case 'get': print(await api('GET', `/k/${need(sub, '<KEY>')}`)); break;
|
|
138
|
+
case 'activity': print(await api('GET', `/projects/${need(sub, '<PROJECT>')}/activity`)); break;
|
|
139
|
+
case 'search': {
|
|
140
|
+
const q = new URLSearchParams(clean({ project: need(sub, '<PROJECT>'), q: rest.join(' ') || undefined, type: o.type, status: o.status, area: o.area, tag: o.tag }));
|
|
141
|
+
print(await api('GET', `/search?${q}`));
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case 'area':
|
|
145
|
+
print(await api('POST', '/areas', clean({ project: need(sub, '<PROJECT>'), title: need(o.title, '--title'), route: o.route, kind: o.kind, parent: o.parent, notes: o.notes, ref: o.ref, key: o.key })));
|
|
146
|
+
break;
|
|
147
|
+
case 'scenario':
|
|
148
|
+
print(await api('POST', '/scenarios', clean({ project: need(sub, '<PROJECT>'), title: need(o.title, '--title'), area: o.area, narrative: o.narrative, ref: o.ref, key: o.key })));
|
|
149
|
+
break;
|
|
150
|
+
case 'cases': {
|
|
151
|
+
const data = JSON.parse(readInput(need(rest[0], '<file.json|->')));
|
|
152
|
+
print(await api('POST', '/cases', { project: need(sub, '<PROJECT>'), cases: Array.isArray(data) ? data : data.cases }));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'edit': print(await api('PATCH', `/k/${need(sub, '<KEY>')}`, JSON.parse(need(rest[0], "'<json>'")))); break;
|
|
156
|
+
case 'approve': {
|
|
157
|
+
const keys = [sub, ...rest].filter(Boolean);
|
|
158
|
+
print(await api('POST', '/cases/bulk', { project: need(keys[0], '<KEY>').split('-')[0], keys, set: { status: 'actual' } }));
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'run': {
|
|
162
|
+
if (sub === 'start') {
|
|
163
|
+
print(await api('POST', '/runs', clean({ project: need(rest[0], '<PROJECT>'), title: need(o.title, '--title'), plan: o.plan, area: o.area, scenario: o.scenario, cases: o.cases?.split(','), environment: o.env })));
|
|
164
|
+
} else if (sub === 'next') print(await api('GET', `/k/${need(rest[0], '<RUN>')}/next`));
|
|
165
|
+
else if (sub === 'complete' || sub === 'abort') print(await api('POST', `/k/${need(rest[0], '<RUN>')}/${sub}`));
|
|
166
|
+
else die('run start|next|complete|abort');
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'result': {
|
|
170
|
+
const [kase, status] = rest;
|
|
171
|
+
print(await api('POST', `/k/${need(sub, '<RUN>')}/results`, clean({
|
|
172
|
+
case: need(kase, '<CASE>'), status: need(status, '<status>'), comment: o.message, durationMs: o.duration ? Number(o.duration) : undefined,
|
|
173
|
+
attachments: o.attach?.map((f) => file(f)),
|
|
174
|
+
})));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'defects':
|
|
178
|
+
print(await api('GET', `/defects?${new URLSearchParams(clean({ project: need(sub, '<PROJECT>'), status: o.status, assignee: o.mine ? 'me' : undefined }))}`));
|
|
179
|
+
break;
|
|
180
|
+
case 'verification': print(await api('GET', `/projects/${need(sub, '<PROJECT>')}/verification`)); break;
|
|
181
|
+
case 'defect': {
|
|
182
|
+
const key = need(rest[0], '<KEY>');
|
|
183
|
+
if (sub === 'report') {
|
|
184
|
+
print(await api('POST', '/defects', clean({
|
|
185
|
+
project: key, title: need(o.title, '--title'), case: o.case, run: o.run, area: o.area, url: o.url, actualResult: o.actual, expected: o.expected,
|
|
186
|
+
reproSteps: o.steps, severity: o.severity, attachments: o.attach?.map((f) => file(f)),
|
|
187
|
+
})));
|
|
188
|
+
} else if (sub === 'claim') print(await api('POST', `/k/${key}/claim${o.force ? '?force=1' : ''}`));
|
|
189
|
+
else if (sub === 'release') print(await api('POST', `/k/${key}/release`));
|
|
190
|
+
else if (sub === 'resolve') print(await api('POST', `/k/${key}/resolve`, clean({ notes: need(o.message, '-m NOTES'), commit: o.commit, prUrl: o.pr })));
|
|
191
|
+
else if (['verify', 'reopen', 'invalid'].includes(sub)) print(await api('POST', `/k/${key}/${sub}`, clean({ comment: o.message })));
|
|
192
|
+
else die('defect report|claim|release|resolve|verify|reopen|invalid');
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'comment': print(await api('POST', `/k/${need(sub, '<KEY>')}/comments`, { body: need(o.message, '-m TEXT') })); break;
|
|
196
|
+
case 'attach': print(await api('POST', `/k/${need(sub, '<KEY>')}/attachments`, file(need(rest[0], '<FILE>'), o.kind), { md: false })); break;
|
|
197
|
+
case 'import': {
|
|
198
|
+
const [code, path] = rest;
|
|
199
|
+
const text = readInput(need(path, '<FILE>'));
|
|
200
|
+
if (sub === 'openapi') print(await api('POST', `/projects/${need(code, '<PROJECT>')}/import/openapi`, { text }));
|
|
201
|
+
else if (sub === 'csv') print(await api('POST', `/projects/${need(code, '<PROJECT>')}/import/csv`, { csv: text }));
|
|
202
|
+
else die('import openapi|csv <PROJECT> <FILE>');
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'export': print(await api('GET', `/projects/${need(rest[0], '<PROJECT>')}/export/csv`, undefined, { md: false, raw: true })); break;
|
|
206
|
+
case 'api': print(await api(need(sub, '<METHOD>').toUpperCase(), need(rest[0], '<PATH>'), rest[1] ? JSON.parse(rest[1]) : undefined)); break;
|
|
207
|
+
default: die(`Unknown command "${cmd}". Run "kasauti --help".`);
|
|
208
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@isummation/kasauti-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Kasauti test management CLI for humans, CI and AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kasauti": "./kasauti.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"kasauti.mjs",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git@gitlab.isummation.com:isummation-product/kasauti.git",
|
|
19
|
+
"directory": "packages/cli"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "restricted",
|
|
23
|
+
"registry": "https://registry.npmjs.org/"
|
|
24
|
+
}
|
|
25
|
+
}
|