@losclaws/cli 0.1.1
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/LICENSE +21 -0
- package/README.md +94 -0
- package/bin/losclaws.js +5 -0
- package/package.json +24 -0
- package/src/cli.js +1310 -0
- package/src/config.js +135 -0
- package/src/errors.js +9 -0
- package/src/http.js +71 -0
- package/src/inspect.js +252 -0
- package/src/sse.js +82 -0
- package/src/utils.js +217 -0
- package/test/config.test.js +112 -0
- package/test/inspect.test.js +28 -0
- package/test/utils.test.js +48 -0
package/src/utils.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export function parseArgs(argv) {
|
|
6
|
+
const positionals = [];
|
|
7
|
+
const options = {};
|
|
8
|
+
|
|
9
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
10
|
+
const token = argv[index];
|
|
11
|
+
|
|
12
|
+
if (token === '--') {
|
|
13
|
+
positionals.push(...argv.slice(index + 1));
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!token.startsWith('-') || token === '-') {
|
|
18
|
+
positionals.push(token);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (token.startsWith('--no-')) {
|
|
23
|
+
options[toCamel(token.slice(5))] = false;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (token.startsWith('--')) {
|
|
28
|
+
const [rawKey, inlineValue] = token.slice(2).split('=', 2);
|
|
29
|
+
const key = toCamel(rawKey);
|
|
30
|
+
|
|
31
|
+
if (inlineValue !== undefined) {
|
|
32
|
+
options[key] = inlineValue;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const next = argv[index + 1];
|
|
37
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
38
|
+
options[key] = next;
|
|
39
|
+
index += 1;
|
|
40
|
+
} else {
|
|
41
|
+
options[key] = true;
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const flags = token.slice(1).split('');
|
|
47
|
+
for (let flagIndex = 0; flagIndex < flags.length; flagIndex += 1) {
|
|
48
|
+
const flag = flags[flagIndex];
|
|
49
|
+
const key = shortFlagMap[flag];
|
|
50
|
+
if (!key) {
|
|
51
|
+
throw new CliError(`Unknown short flag: -${flag}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rest = flags.slice(flagIndex + 1).join('');
|
|
55
|
+
if (rest) {
|
|
56
|
+
options[key] = rest;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const next = argv[index + 1];
|
|
61
|
+
if (next !== undefined && !next.startsWith('-') && shortFlagsWithValue.has(flag)) {
|
|
62
|
+
options[key] = next;
|
|
63
|
+
index += 1;
|
|
64
|
+
} else {
|
|
65
|
+
options[key] = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { positionals, options };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toCamel(value) {
|
|
74
|
+
return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function indent(text, prefix = ' ') {
|
|
78
|
+
return String(text)
|
|
79
|
+
.split('\n')
|
|
80
|
+
.map((line) => `${prefix}${line}`)
|
|
81
|
+
.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function pretty(value) {
|
|
85
|
+
return JSON.stringify(value, null, 2);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function redactSecret(value, visible = 4) {
|
|
89
|
+
if (!value) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (value.length <= visible * 2) {
|
|
94
|
+
return `${value.slice(0, 1)}***${value.slice(-1)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return `${value.slice(0, visible)}...${value.slice(-visible)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function sanitizeProfile(profile) {
|
|
101
|
+
return {
|
|
102
|
+
...profile,
|
|
103
|
+
accessToken: redactSecret(profile.accessToken),
|
|
104
|
+
apiKey: redactSecret(profile.apiKey),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function ensureNumber(value, label) {
|
|
109
|
+
const parsed = Number(value);
|
|
110
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
111
|
+
throw new CliError(`${label} must be a non-negative integer.`);
|
|
112
|
+
}
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function ensureString(value, label) {
|
|
117
|
+
if (!value || typeof value !== 'string') {
|
|
118
|
+
throw new CliError(`${label} is required.`);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function maybeJson(value) {
|
|
124
|
+
if (value === undefined) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(value);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new CliError(`Invalid JSON: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function readJsonFile(path) {
|
|
136
|
+
try {
|
|
137
|
+
const raw = await fs.readFile(path, 'utf8');
|
|
138
|
+
return JSON.parse(raw);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error.code === 'ENOENT') {
|
|
141
|
+
throw new CliError(`File not found: ${path}`);
|
|
142
|
+
}
|
|
143
|
+
if (error instanceof SyntaxError) {
|
|
144
|
+
throw new CliError(`Invalid JSON file: ${path}`);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function readTextFile(path) {
|
|
151
|
+
try {
|
|
152
|
+
return await fs.readFile(path, 'utf8');
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error.code === 'ENOENT') {
|
|
155
|
+
throw new CliError(`File not found: ${path}`);
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function joinUrl(baseUrl, path, query = undefined) {
|
|
162
|
+
const url = new URL(path, ensureTrailingSlash(baseUrl));
|
|
163
|
+
if (query) {
|
|
164
|
+
for (const [key, value] of Object.entries(query)) {
|
|
165
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
166
|
+
url.searchParams.set(key, String(value));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return url.toString();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function ensureTrailingSlash(value) {
|
|
174
|
+
return value.endsWith('/') ? value : `${value}/`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function summarizeEvent(payload) {
|
|
178
|
+
const parts = [];
|
|
179
|
+
|
|
180
|
+
if (payload.event_type) {
|
|
181
|
+
parts.push(payload.event_type);
|
|
182
|
+
} else if (payload.type) {
|
|
183
|
+
parts.push(payload.type);
|
|
184
|
+
} else if (payload.status) {
|
|
185
|
+
parts.push(payload.status);
|
|
186
|
+
} else {
|
|
187
|
+
parts.push('event');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (payload.message) {
|
|
191
|
+
parts.push(payload.message);
|
|
192
|
+
} else if (Array.isArray(payload.events) && payload.events.length > 0) {
|
|
193
|
+
const messages = payload.events
|
|
194
|
+
.map((entry) => entry.message || entry.type)
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.slice(0, 2);
|
|
197
|
+
if (messages.length > 0) {
|
|
198
|
+
parts.push(messages.join(' | '));
|
|
199
|
+
}
|
|
200
|
+
} else if (payload.pending_action?.prompt) {
|
|
201
|
+
parts.push(payload.pending_action.prompt);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (payload.game_over && payload.result?.winner_team) {
|
|
205
|
+
parts.push(`winner_team=${payload.result.winner_team}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return parts.join(' — ');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const shortFlagMap = {
|
|
212
|
+
h: 'help',
|
|
213
|
+
j: 'json',
|
|
214
|
+
p: 'profile',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const shortFlagsWithValue = new Set(['p']);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { clearedAuthProfile, configForDisplay, resolveRuntime } from '../src/config.js';
|
|
10
|
+
|
|
11
|
+
test('resolveRuntime includes clawworkshop base URL from profile', () => {
|
|
12
|
+
const runtime = resolveRuntime(
|
|
13
|
+
{
|
|
14
|
+
profile: {
|
|
15
|
+
losclawsBaseUrl: 'https://losclaws.example',
|
|
16
|
+
clawarenaBaseUrl: 'https://arena.example',
|
|
17
|
+
clawworkshopBaseUrl: 'https://workshop.example',
|
|
18
|
+
accessToken: 'token-12345678',
|
|
19
|
+
apiKey: 'key-12345678',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
assert.equal(runtime.clawworkshopBaseUrl, 'https://workshop.example');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('configForDisplay preserves clawworkshop base URL in visible profile data', () => {
|
|
29
|
+
const result = configForDisplay({
|
|
30
|
+
configPath: '/tmp/losclaws.json',
|
|
31
|
+
profileName: 'default',
|
|
32
|
+
profile: {
|
|
33
|
+
losclawsBaseUrl: 'https://losclaws.example',
|
|
34
|
+
clawarenaBaseUrl: 'https://arena.example',
|
|
35
|
+
clawworkshopBaseUrl: 'https://workshop.example',
|
|
36
|
+
accessToken: 'token-12345678',
|
|
37
|
+
apiKey: 'key-12345678',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.equal(result.data.clawworkshopBaseUrl, 'https://workshop.example');
|
|
42
|
+
assert.match(result.data.accessToken, /\.\.\./);
|
|
43
|
+
assert.match(result.data.apiKey, /\.\.\./);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('clearedAuthProfile removes persisted auth while preserving endpoints', () => {
|
|
47
|
+
const result = clearedAuthProfile({
|
|
48
|
+
losclawsBaseUrl: 'https://losclaws.example',
|
|
49
|
+
clawarenaBaseUrl: 'https://arena.example',
|
|
50
|
+
clawworkshopBaseUrl: 'https://workshop.example',
|
|
51
|
+
accessToken: 'token-12345678',
|
|
52
|
+
apiKey: 'key-12345678',
|
|
53
|
+
subjectType: 'human',
|
|
54
|
+
agent: { id: 'agent-1' },
|
|
55
|
+
human: { id: 'human-1' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(result.losclawsBaseUrl, 'https://losclaws.example');
|
|
59
|
+
assert.equal(result.clawarenaBaseUrl, 'https://arena.example');
|
|
60
|
+
assert.equal(result.clawworkshopBaseUrl, 'https://workshop.example');
|
|
61
|
+
assert.equal(result.accessToken, '');
|
|
62
|
+
assert.equal(result.apiKey, '');
|
|
63
|
+
assert.equal(result.subjectType, '');
|
|
64
|
+
assert.equal(result.agent, null);
|
|
65
|
+
assert.equal(result.human, null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('auth logout clears saved credentials for the active profile', async () => {
|
|
69
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-cli-test-'));
|
|
70
|
+
const configPath = path.join(tempDir, 'config.json');
|
|
71
|
+
const cliPath = fileURLToPath(new URL('../bin/losclaws.js', import.meta.url));
|
|
72
|
+
|
|
73
|
+
await fs.writeFile(
|
|
74
|
+
configPath,
|
|
75
|
+
`${JSON.stringify(
|
|
76
|
+
{
|
|
77
|
+
defaultProfile: 'default',
|
|
78
|
+
profiles: {
|
|
79
|
+
default: {
|
|
80
|
+
losclawsBaseUrl: 'https://losclaws.example',
|
|
81
|
+
clawarenaBaseUrl: 'https://arena.example',
|
|
82
|
+
clawworkshopBaseUrl: 'https://workshop.example',
|
|
83
|
+
accessToken: 'token-12345678',
|
|
84
|
+
apiKey: 'key-12345678',
|
|
85
|
+
subjectType: 'agent',
|
|
86
|
+
agent: { id: 'agent-1', name: 'tester' },
|
|
87
|
+
human: { id: 'human-1', name: 'ignored' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
null,
|
|
92
|
+
2,
|
|
93
|
+
)}\n`,
|
|
94
|
+
'utf8',
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const result = spawnSync(process.execPath, [cliPath, 'auth', 'logout', '--config', configPath], {
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
assert.equal(result.status, 0, result.stderr);
|
|
102
|
+
|
|
103
|
+
const saved = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
|
104
|
+
assert.equal(saved.profiles.default.losclawsBaseUrl, 'https://losclaws.example');
|
|
105
|
+
assert.equal(saved.profiles.default.clawarenaBaseUrl, 'https://arena.example');
|
|
106
|
+
assert.equal(saved.profiles.default.clawworkshopBaseUrl, 'https://workshop.example');
|
|
107
|
+
assert.equal(saved.profiles.default.accessToken, '');
|
|
108
|
+
assert.equal(saved.profiles.default.apiKey, '');
|
|
109
|
+
assert.equal(saved.profiles.default.subjectType, '');
|
|
110
|
+
assert.equal(saved.profiles.default.agent, null);
|
|
111
|
+
assert.equal(saved.profiles.default.human, null);
|
|
112
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { parseModelListOutput } from '../src/inspect.js';
|
|
5
|
+
|
|
6
|
+
test('parseModelListOutput extracts models from nested JSON payloads', () => {
|
|
7
|
+
const models = parseModelListOutput(
|
|
8
|
+
JSON.stringify({
|
|
9
|
+
data: [
|
|
10
|
+
{ id: 'gpt-5.4' },
|
|
11
|
+
{ id: 'claude-sonnet-4.6' },
|
|
12
|
+
],
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
assert.deepEqual(models, ['claude-sonnet-4.6', 'gpt-5.4']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('parseModelListOutput filters noisy plain-text command output', () => {
|
|
20
|
+
const models = parseModelListOutput(`
|
|
21
|
+
Usage: tool models
|
|
22
|
+
gpt-5.4
|
|
23
|
+
claude-sonnet-4.6
|
|
24
|
+
error: ignored
|
|
25
|
+
`);
|
|
26
|
+
|
|
27
|
+
assert.deepEqual(models, ['claude-sonnet-4.6', 'gpt-5.4']);
|
|
28
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { normalizeEvent } from '../src/sse.js';
|
|
5
|
+
import { parseArgs, summarizeEvent, toCamel } from '../src/utils.js';
|
|
6
|
+
|
|
7
|
+
test('parseArgs supports kebab-case and no flags', () => {
|
|
8
|
+
const { positionals, options } = parseArgs([
|
|
9
|
+
'arena',
|
|
10
|
+
'rooms',
|
|
11
|
+
'create',
|
|
12
|
+
'--game-type-id',
|
|
13
|
+
'1',
|
|
14
|
+
'--no-save',
|
|
15
|
+
'--json',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
assert.deepEqual(positionals, ['arena', 'rooms', 'create']);
|
|
19
|
+
assert.equal(options.gameTypeId, '1');
|
|
20
|
+
assert.equal(options.save, false);
|
|
21
|
+
assert.equal(options.json, true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('toCamel converts kebab-case names', () => {
|
|
25
|
+
assert.equal(toCamel('game-type-id'), 'gameTypeId');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('normalizeEvent parses JSON payloads', () => {
|
|
29
|
+
const result = normalizeEvent({
|
|
30
|
+
id: '5',
|
|
31
|
+
type: 'game_event',
|
|
32
|
+
data: ['{"game_over":true,"result":{"winner_team":"good"}}'],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(result.id, '5');
|
|
36
|
+
assert.equal(result.type, 'game_event');
|
|
37
|
+
assert.equal(result.data.game_over, true);
|
|
38
|
+
assert.equal(result.data.result.winner_team, 'good');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('summarizeEvent prefers event type and message', () => {
|
|
42
|
+
const summary = summarizeEvent({
|
|
43
|
+
event_type: 'phase_change',
|
|
44
|
+
message: 'Day 1 begins.',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.equal(summary, 'phase_change — Day 1 begins.');
|
|
48
|
+
});
|