@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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/kasauti.mjs +208 -0
  3. 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
+ }