@solo-kb/solo-learn 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ # @solo-kb/solo-learn
2
+
3
+ CLI for connecting agents to the Solo Learn production learning system.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @solo-kb/solo-learn
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ solo-learn login
15
+ solo-learn --json whoami
16
+ solo-learn --json sessions list
17
+ solo-learn --json sessions get <session_id>
18
+ solo-learn --json steps submit <session_id> <step_id> --answer-file answer.md
19
+ ```
20
+
21
+ Agent integrations should prefer `--json` for stable machine-readable output.
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ const DEFAULT_BASE_URL = 'https://kb.aipon.cn';
7
+
8
+ class CliError extends Error {
9
+ constructor(code, message, status = 1) {
10
+ super(message);
11
+ this.code = code;
12
+ this.status = status;
13
+ }
14
+ }
15
+
16
+ function parseGlobal(argv) {
17
+ const options = { json: false, profile: 'default' };
18
+ const args = [];
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const arg = argv[i];
21
+ if (arg === '--json') options.json = true;
22
+ else if (arg === '--profile') {
23
+ options.profile = argv[++i];
24
+ if (!options.profile) throw new CliError('usage', '--profile 需要名称');
25
+ } else args.push(arg);
26
+ }
27
+ return { options, args };
28
+ }
29
+
30
+ function homeDir() {
31
+ return process.env.SOLO_LEARN_HOME || join(homedir(), '.solo-learn');
32
+ }
33
+
34
+ function profilePath(name) {
35
+ return join(homeDir(), 'profiles', `${name}.json`);
36
+ }
37
+
38
+ function readProfile(name) {
39
+ const path = profilePath(name);
40
+ if (!existsSync(path)) throw new CliError('unauthorized', '请先运行 solo-learn login');
41
+ try {
42
+ return JSON.parse(readFileSync(path, 'utf8'));
43
+ } catch (error) {
44
+ throw new CliError('profile_invalid', `profile 无法读取:${error.message}`);
45
+ }
46
+ }
47
+
48
+ function safeProfile(profile) {
49
+ return Object.fromEntries(Object.entries(profile).filter(([key]) => key !== 'accessToken'));
50
+ }
51
+
52
+ function writeProfile(name, profile) {
53
+ const path = profilePath(name);
54
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
55
+ writeFileSync(path, JSON.stringify(profile, null, 2));
56
+ chmodSync(path, 0o600);
57
+ }
58
+
59
+ function removeProfile(name) {
60
+ const path = profilePath(name);
61
+ if (existsSync(path)) rmSync(path);
62
+ }
63
+
64
+ function parseCommandOptions(args) {
65
+ const options = {};
66
+ const rest = [];
67
+ for (let i = 0; i < args.length; i += 1) {
68
+ const arg = args[i];
69
+ if (arg === '--base-url') options.baseUrl = args[++i];
70
+ else if (arg === '--device-code') options.deviceCode = args[++i];
71
+ else if (arg === '--answer-file') options.answerFile = args[++i];
72
+ else rest.push(arg);
73
+ }
74
+ return { options, rest };
75
+ }
76
+
77
+ async function request(profile, method, path, body) {
78
+ const headers = { Authorization: `${profile.tokenType || 'Bearer'} ${profile.accessToken}` };
79
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
80
+ const response = await fetch(`${profile.baseUrl}${path}`, {
81
+ method,
82
+ headers,
83
+ body: body === undefined ? undefined : JSON.stringify(body),
84
+ });
85
+ const text = await response.text();
86
+ const payload = text ? JSON.parse(text) : {};
87
+ if (!response.ok) {
88
+ throw new CliError(httpCode(response.status), payload.error || response.statusText, response.status === 401 ? 1 : 2);
89
+ }
90
+ return payload;
91
+ }
92
+
93
+ async function publicRequest(baseUrl, method, path, body) {
94
+ const response = await fetch(`${baseUrl}${path}`, {
95
+ method,
96
+ headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
97
+ body: body === undefined ? undefined : JSON.stringify(body),
98
+ });
99
+ const text = await response.text();
100
+ const payload = text ? JSON.parse(text) : {};
101
+ if (!response.ok) throw new CliError(httpCode(response.status), payload.error || response.statusText, 2);
102
+ return payload;
103
+ }
104
+
105
+ function httpCode(status) {
106
+ if (status === 401) return 'unauthorized';
107
+ if (status === 403) return 'forbidden';
108
+ if (status === 404) return 'not_found';
109
+ if (status >= 500) return 'service_unavailable';
110
+ return 'request_failed';
111
+ }
112
+
113
+ function stepFromSession(session, stepId) {
114
+ const steps = session?.workshop?.steps || [];
115
+ const step = steps.find((item) => item.id === stepId);
116
+ if (!step) throw new CliError('not_found', `未找到步骤:${stepId}`, 2);
117
+ return step;
118
+ }
119
+
120
+ function allGaps(session) {
121
+ const gaps = session?.gaps || {};
122
+ if (Array.isArray(gaps)) return gaps;
123
+ return Object.entries(gaps).flatMap(([stepId, items]) => (items || []).map((gap) => Object.assign({ step_id: stepId }, gap)));
124
+ }
125
+
126
+ async function handle(args, profileName) {
127
+ const domain = args[0];
128
+ const action = args[1];
129
+ const tail = args.slice(2);
130
+ if (!domain) throw new CliError('usage', '缺少命令');
131
+
132
+ if (domain === 'login') {
133
+ const { options } = parseCommandOptions([action].concat(tail).filter(Boolean));
134
+ const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
135
+ if (options.deviceCode) {
136
+ const result = await publicRequest(baseUrl, 'POST', '/api/cli/auth/device/poll', { device_code: options.deviceCode });
137
+ if (result.status !== 'approved') throw new CliError('authorization_pending', `授权状态:${result.status}`);
138
+ const profile = {
139
+ schemaVersion: 1,
140
+ name: profileName,
141
+ environment: 'production',
142
+ baseUrl,
143
+ tokenType: result.token_type || 'Bearer',
144
+ accessToken: result.access_token,
145
+ user: result.user,
146
+ createdAt: new Date().toISOString(),
147
+ expiresAt: result.expires_at ?? null,
148
+ };
149
+ writeProfile(profileName, profile);
150
+ return safeProfile(profile);
151
+ }
152
+ return publicRequest(baseUrl, 'POST', '/api/cli/auth/device/start');
153
+ }
154
+
155
+ if (domain === 'logout') {
156
+ try {
157
+ const profile = readProfile(profileName);
158
+ await request(profile, 'POST', '/api/cli/auth/revoke', {});
159
+ } catch (error) {
160
+ if (!(error instanceof CliError) || error.code !== 'unauthorized') throw error;
161
+ }
162
+ removeProfile(profileName);
163
+ return { logged_out: true };
164
+ }
165
+
166
+ if (domain === 'profile') {
167
+ if (action === 'list') {
168
+ const dir = join(homeDir(), 'profiles');
169
+ const profiles = existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)) : [];
170
+ return { profiles, current: profileName };
171
+ }
172
+ if (action === 'use') {
173
+ const next = tail[0];
174
+ if (!next) throw new CliError('usage', 'profile use 需要名称');
175
+ readProfile(next);
176
+ return { current: next };
177
+ }
178
+ }
179
+
180
+ const profile = readProfile(profileName);
181
+ if (domain === 'whoami') return request(profile, 'GET', '/api/cli/me');
182
+
183
+ if (domain === 'sessions') {
184
+ if (action === 'list') return request(profile, 'GET', '/api/cli/learning/sessions');
185
+ if (action === 'get') {
186
+ if (!tail[0]) throw new CliError('usage', 'sessions get 需要 session_id');
187
+ return request(profile, 'GET', `/api/cli/learning/sessions/${encodeURIComponent(tail[0])}`);
188
+ }
189
+ if (action === 'create') {
190
+ const goal = tail.join(' ').trim();
191
+ if (!goal) throw new CliError('usage', 'sessions create 需要学习目标');
192
+ return request(profile, 'POST', '/api/cli/learning/sessions', { goal });
193
+ }
194
+ }
195
+
196
+ if (domain === 'steps') {
197
+ const sessionId = tail[0];
198
+ if (!sessionId) throw new CliError('usage', `steps ${action || ''} 需要 session_id`);
199
+ if (action === 'list') {
200
+ const session = await request(profile, 'GET', `/api/cli/learning/sessions/${encodeURIComponent(sessionId)}`);
201
+ return session?.workshop?.steps || [];
202
+ }
203
+ const stepId = tail[1];
204
+ if (!stepId) throw new CliError('usage', `steps ${action || ''} 需要 step_id`);
205
+ if (action === 'get') {
206
+ const session = await request(profile, 'GET', `/api/cli/learning/sessions/${encodeURIComponent(sessionId)}`);
207
+ return stepFromSession(session, stepId);
208
+ }
209
+ if (action === 'submit') {
210
+ const { options } = parseCommandOptions(tail.slice(2));
211
+ if (!options.answerFile) throw new CliError('usage', 'steps submit 需要 --answer-file');
212
+ const answer = readFileSync(options.answerFile, 'utf8');
213
+ return request(profile, 'POST', `/api/cli/learning/sessions/${encodeURIComponent(sessionId)}/steps/${encodeURIComponent(stepId)}/submit`, { answer });
214
+ }
215
+ }
216
+
217
+ if (domain === 'gaps') {
218
+ const sessionId = tail[0];
219
+ if (!sessionId) throw new CliError('usage', `gaps ${action || ''} 需要 session_id`);
220
+ if (action === 'list') {
221
+ const session = await request(profile, 'GET', `/api/cli/learning/sessions/${encodeURIComponent(sessionId)}`);
222
+ return allGaps(session);
223
+ }
224
+ if (action === 'add') {
225
+ const stepId = tail[1];
226
+ const question = tail.slice(2).join(' ').trim();
227
+ if (!stepId || !question) throw new CliError('usage', 'gaps add 需要 step_id 和问题');
228
+ return request(profile, 'POST', `/api/cli/learning/sessions/${encodeURIComponent(sessionId)}/steps/${encodeURIComponent(stepId)}/gaps`, { question });
229
+ }
230
+ if (action === 'resolve') {
231
+ const gapId = tail[1];
232
+ if (!gapId) throw new CliError('usage', 'gaps resolve 需要 gap_id');
233
+ return request(profile, 'POST', `/api/cli/learning/sessions/${encodeURIComponent(sessionId)}/gaps/${encodeURIComponent(gapId)}/resolve`, {});
234
+ }
235
+ }
236
+
237
+ throw new CliError('usage', `未知命令:${[domain, action].filter(Boolean).join(' ')}`);
238
+ }
239
+
240
+ function printResult(options, data) {
241
+ if (options.json) console.log(JSON.stringify({ ok: true, data }));
242
+ else console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
243
+ }
244
+
245
+ function printError(options, error) {
246
+ const payload = { ok: false, error: { code: error.code || 'error', message: error.message || String(error) } };
247
+ if (options.json) console.log(JSON.stringify(payload));
248
+ else console.error(payload.error.message);
249
+ }
250
+
251
+ const { options, args } = parseGlobal(process.argv.slice(2));
252
+ try {
253
+ const data = await handle(args, options.profile);
254
+ printResult(options, data);
255
+ } catch (error) {
256
+ const cliError = error instanceof CliError ? error : new CliError('error', error.message || String(error), 1);
257
+ printError(options, cliError);
258
+ process.exit(cliError.status || 1);
259
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@solo-kb/solo-learn",
3
+ "version": "0.1.0",
4
+ "description": "CLI for connecting agents to the Solo Learn production learning system.",
5
+ "type": "module",
6
+ "bin": {
7
+ "solo-learn": "bin/solo-learn.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://gitee.com/lab-catty/solo-kb.git",
16
+ "directory": "cli/solo-learn"
17
+ },
18
+ "homepage": "https://gitee.com/lab-catty/solo-kb",
19
+ "bugs": {
20
+ "url": "https://gitee.com/lab-catty/solo-kb/issues"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "README.md"
25
+ ]
26
+ }