@kesflow/kf 0.0.0-stage → 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/README.md +32 -2
- package/SKILL.md +37 -0
- package/dist/api.d.ts +8 -0
- package/dist/api.js +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +220 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +62 -0
- package/dist/kesflow.d.ts +4 -0
- package/dist/kesflow.js +15 -0
- package/dist/ops.d.ts +24 -0
- package/dist/ops.js +101 -0
- package/dist/upload.d.ts +9 -0
- package/dist/upload.js +85 -0
- package/package.json +29 -4
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@kesflow/kf`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CLI for KesFlow staff agents. Requires Node.js 22 or newer and a personal token from **Nội bộ → Kết nối của tôi**.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @kesflow/kf
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Đăng nhập: bảo người dùng mở một terminal riêng và gõ `kf login`, rồi dán token vào ô ẩn (ô chat Claude Code với `!` không có terminal nên không dùng được). Agent không tự chạy `kf login`, không bao giờ hỏi hay nhận token trong chat. `kf logout` removes the local file; revoke the token on the ops page.
|
|
10
|
+
|
|
11
|
+
Commands use the operation IDs from `GET /ops` exactly: `kf <op-id>`. Run `kf --help` for the live list, `kf schema <op-id>` for JSON Schema, or `kf <op-id> --help` for flags. The CLI keeps no persistent operations cache.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
kf status
|
|
15
|
+
kf ops
|
|
16
|
+
kf lms-buoi --lop-ma THUCLI
|
|
17
|
+
kf lms-doi-ten --tep-id UUID --ten 'Tên mới'
|
|
18
|
+
kf lms-ghi-recap --json '{"buoi_id":"UUID","recap_md":"# Buổi học","de_bai_md":null}'
|
|
19
|
+
kf lms-dang-buoi --buoi-id UUID --gui-thu false --yes
|
|
20
|
+
kf lms upload slide.html --buoi-id UUID
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Use `--dry-run` to validate an operation without writing and `--pretty` for indented JSON. High-risk operations require `--yes`. Normal output is one JSON line on stdout; errors are one JSON line on stderr. Exit codes: 0 success, 2 usage, 3 authentication or permission, 4 business error, 5 server or network.
|
|
24
|
+
|
|
25
|
+
Credentials are stored in `~/.config/kf/credentials.json` (directory mode 700, file mode 600). `KF_API_URL` is honoured by `kf login` and saved with the token. Changing it later requires another login. Plain HTTP is accepted only for localhost loopback tests.
|
|
26
|
+
|
|
27
|
+
## Skill cho Claude Code
|
|
28
|
+
|
|
29
|
+
Gói kèm skill `kf-cli` (hướng dẫn agent dùng `kf` đúng cách). Cài một lần:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
mkdir -p ~/.claude/skills/kf-cli && cp "$(npm root -g)/@kesflow/kf/SKILL.md" ~/.claude/skills/kf-cli/SKILL.md
|
|
33
|
+
```
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kf-cli
|
|
3
|
+
description: Thao tác nội bộ KesFlow qua CLI `kf` thay cho bấm màn ops — chốt buổi học LMS (gắn record từ Drive, tải slide, đổi tên/gỡ tệp, ghi recap/bài tập/chuẩn bị, đăng buổi), xem lớp và buổi mình phụ trách. Dùng khi nhân sự KesFlow nhờ "chốt buổi", "up slide/record lên LMS", "ghi recap", "đăng buổi", "đổi tên record", "xem buổi lớp TA05" hoặc nhắc tới `kf`. Không dùng cho việc ngoài danh sách `kf --help` (tiền, ghi danh, gửi mail tự do chưa có trong CLI).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# kf — CLI nội bộ KesFlow cho agent
|
|
7
|
+
|
|
8
|
+
`kf` gọi Agent API của KesFlow **nhân danh người đang đăng nhập**; quyền do DB quyết định theo vai của người đó, y như màn ops.
|
|
9
|
+
|
|
10
|
+
## Đăng nhập — người dùng tự làm, KHÔNG qua chat
|
|
11
|
+
- Chưa đăng nhập (`kf status` thoát mã 3, `NOT_LOGGED_IN`) hoặc token hết hạn/thu hồi (401): bảo người dùng
|
|
12
|
+
1. vào ops → **Kết nối của tôi** → **Tạo token** (đặt tên máy);
|
|
13
|
+
2. **mở một terminal riêng** (ô chat Claude Code với `!` không có terminal nên không dùng được), gõ `kf login`, dán token vào ô ẩn.
|
|
14
|
+
- **Không bao giờ** hỏi, nhận, đọc, in hay ghi token. Không tự chạy `kf login`. Không đọc `~/.config/kf/credentials.json`.
|
|
15
|
+
|
|
16
|
+
## Cách dùng
|
|
17
|
+
- Danh sách thao tác (lấy trực tiếp từ server, luôn đúng): `kf --help`. Tham số một thao tác: `kf <op> --help`, `kf schema <op>`.
|
|
18
|
+
- Gọi: `kf <op> --<tham-so> <giá trị>` hoặc `kf <op> --json '{...}'`. Kết quả: một dòng JSON (`--pretty` để đọc).
|
|
19
|
+
- Không chắc thì chạy thử trước: thêm `--dry-run` (chỉ kiểm, không ghi gì).
|
|
20
|
+
- Tải tệp từ máy lên buổi học: `kf lms upload <tệp> --buoi-id <id> [--loai slide_pdf|slide_html|record|video_xem_truoc] [--ten ...]`.
|
|
21
|
+
- Tìm `buoi-id`: `kf status` (lớp tôi phụ trách) → `kf lms-buoi --lop-ma <MÃ LỚP>`.
|
|
22
|
+
|
|
23
|
+
## Thao tác rủi ro cao (`[high]`, cần `--yes`)
|
|
24
|
+
- Ví dụ `lms-dang-buoi --gui-thu true` gửi thư cho **cả lớp thật**.
|
|
25
|
+
- Chỉ thêm `--yes` **sau khi người dùng đồng ý rõ trong chat** với đúng nội dung: buổi nào, lớp nào, có gửi thư không. Không tự suy ra ý định gửi thư.
|
|
26
|
+
- `--yes` không thay người duyệt; người dùng nên để Claude Code hỏi quyền trước lệnh `kf … --yes`.
|
|
27
|
+
|
|
28
|
+
## Đọc kết quả
|
|
29
|
+
| Mã thoát | Nghĩa | Làm gì |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| 0 | OK | báo kết quả |
|
|
32
|
+
| 2 | Sai cách dùng / thiếu `--yes` / cấu hình | sửa lệnh; cấu hình lỗi → nhắc đăng nhập lại ở terminal riêng |
|
|
33
|
+
| 3 | 401 (token) / 403 `khong_vai` (không có vai với lớp/buổi đó) | 401 → nhắc đăng nhập lại; 403 → báo người dùng không có quyền, không thử đường khác |
|
|
34
|
+
| 4 | Lỗi nghiệp vụ (`LMS_*`, vd `LMS_DA_CO_RECORD`) | đọc mã, báo người dùng |
|
|
35
|
+
| 5 | Mạng/server; `AGENT_TAT` = Agent API đang tắt | báo người dùng, không thử lại liên tục |
|
|
36
|
+
|
|
37
|
+
`lms-dang-buoi` có thể trả `da_dang:false, thieu:["record"]`: buổi chưa có record sẵn sàng nên chưa đăng — không phải lỗi.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare class CliError extends Error {
|
|
2
|
+
readonly exitCode: number;
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly http: number | null;
|
|
5
|
+
constructor(message: string, exitCode: number, code: string, http?: number | null);
|
|
6
|
+
}
|
|
7
|
+
export declare function httpExit(status: number): number;
|
|
8
|
+
export declare function request(baseUrl: string, token: string, path: string, body?: Record<string, unknown>): Promise<unknown>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
exitCode;
|
|
3
|
+
code;
|
|
4
|
+
http;
|
|
5
|
+
constructor(message, exitCode, code, http = null) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.http = http;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function httpExit(status) {
|
|
13
|
+
if (status === 401 || status === 403)
|
|
14
|
+
return 3;
|
|
15
|
+
if (status >= 400 && status < 500)
|
|
16
|
+
return 4;
|
|
17
|
+
return 5;
|
|
18
|
+
}
|
|
19
|
+
export async function request(baseUrl, token, path, body) {
|
|
20
|
+
let response;
|
|
21
|
+
try {
|
|
22
|
+
response = await fetch(`${baseUrl}/${path}`, {
|
|
23
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
24
|
+
headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
signal: AbortSignal.timeout(30_000),
|
|
27
|
+
redirect: 'error',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const redirect = error instanceof TypeError && /redirect/i.test(String(error.cause ?? error.message));
|
|
32
|
+
throw new CliError(redirect ? 'Agent API trả chuyển hướng — bị chặn để giữ token' : 'Không kết nối được Agent API', 5, redirect ? 'REDIRECT' : 'NETWORK');
|
|
33
|
+
}
|
|
34
|
+
if (response.status === 401)
|
|
35
|
+
throw new CliError('token hết hạn/đã thu hồi — mở terminal riêng và gõ `kf login`', 3, 'HTTP_401', 401);
|
|
36
|
+
let data;
|
|
37
|
+
try {
|
|
38
|
+
data = await response.json();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new CliError('Agent API trả dữ liệu không hợp lệ', 5, 'BAD_RESPONSE', response.status);
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const obj = data && typeof data === 'object' ? data : {};
|
|
45
|
+
const candidate = typeof obj.ma === 'string' ? obj.ma : typeof obj.loai === 'string' ? obj.loai : '';
|
|
46
|
+
const code = /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(candidate) ? candidate : `HTTP_${response.status}`;
|
|
47
|
+
throw new CliError(code, httpExit(response.status), code, response.status);
|
|
48
|
+
}
|
|
49
|
+
return data;
|
|
50
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { stdin, stderr, stdout } from 'node:process';
|
|
3
|
+
import { openSync } from 'node:fs';
|
|
4
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
5
|
+
import { ReadStream } from 'node:tty';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { CliError, request } from './api.js';
|
|
8
|
+
import { apiUrl, deleteCredentials, readCredentials, saveCredentials } from './config.js';
|
|
9
|
+
import { operationHelp, parseBody, parseOps } from './ops.js';
|
|
10
|
+
import { upload } from './upload.js';
|
|
11
|
+
import { QUICKSTART } from './kesflow.js';
|
|
12
|
+
function print(value, pretty = false, token = '') {
|
|
13
|
+
const encoded = redact(JSON.stringify(value, null, pretty ? 2 : undefined), token);
|
|
14
|
+
stdout.write(`${encoded}\n`);
|
|
15
|
+
}
|
|
16
|
+
function redact(value, token = '') {
|
|
17
|
+
return value.replaceAll(token || '\u0000', '[REDACTED]').replace(/kf_pat_[0-9a-f]{64}/g, '[REDACTED]');
|
|
18
|
+
}
|
|
19
|
+
function labelFromMe(value) {
|
|
20
|
+
const envelope = value && typeof value === 'object' ? value : {};
|
|
21
|
+
const data = envelope.du_lieu && typeof envelope.du_lieu === 'object' ? envelope.du_lieu : envelope;
|
|
22
|
+
for (const key of ['email', 'ten', 'name'])
|
|
23
|
+
if (typeof data[key] === 'string')
|
|
24
|
+
return data[key];
|
|
25
|
+
return 'KesFlow staff';
|
|
26
|
+
}
|
|
27
|
+
export async function hiddenToken() {
|
|
28
|
+
let fd = null;
|
|
29
|
+
let input;
|
|
30
|
+
if (stdin.isTTY && typeof stdin.setRawMode === 'function')
|
|
31
|
+
input = stdin;
|
|
32
|
+
else {
|
|
33
|
+
try {
|
|
34
|
+
fd = openSync('/dev/tty', 'r+');
|
|
35
|
+
input = new ReadStream(fd);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new CliError('kf login cần một terminal thật: mở terminal riêng và gõ `kf login` (ô chat Claude Code với ! không có terminal)', 2, 'TTY_REQUIRED');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let token = '';
|
|
42
|
+
emitKeypressEvents(input);
|
|
43
|
+
input.setRawMode(true);
|
|
44
|
+
input.resume();
|
|
45
|
+
try {
|
|
46
|
+
return await new Promise((resolve, reject) => {
|
|
47
|
+
const onKeypress = (text, key) => {
|
|
48
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
49
|
+
cleanup();
|
|
50
|
+
stderr.write('\n');
|
|
51
|
+
resolve(token);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if ((key.ctrl && ['c', 'd'].includes(key.name || ''))) {
|
|
55
|
+
cleanup();
|
|
56
|
+
stderr.write('\n');
|
|
57
|
+
reject(new CliError('Đã huỷ đăng nhập', 2, 'CANCELLED'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.name === 'backspace') {
|
|
61
|
+
token = token.slice(0, -1);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!key.ctrl && !key.meta && text && !text.includes('\x1b') && !key.sequence?.includes('\x1b'))
|
|
65
|
+
token += text;
|
|
66
|
+
};
|
|
67
|
+
const cleanup = () => input.off('keypress', onKeypress);
|
|
68
|
+
input.on('keypress', onKeypress);
|
|
69
|
+
stderr.write('Dán token (ẩn), Enter để tiếp tục: ');
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
input.setRawMode(false);
|
|
74
|
+
input.pause();
|
|
75
|
+
if (fd !== null)
|
|
76
|
+
input.destroy(); // ReadStream owns fd and closes it
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function validToken(token) {
|
|
80
|
+
if (!/^kf_pat_[0-9a-f]{64}$/.test(token))
|
|
81
|
+
throw new CliError('Token không hợp lệ; mở terminal riêng và gõ `kf login` lại', 2, 'TOKEN');
|
|
82
|
+
return token;
|
|
83
|
+
}
|
|
84
|
+
function requireCredentials(value) {
|
|
85
|
+
if (!value)
|
|
86
|
+
throw new CliError('Chưa đăng nhập; mở terminal riêng và gõ kf login', 3, 'NOT_LOGGED_IN');
|
|
87
|
+
validToken(value.token);
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function uploadArgs(args) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = parseArgs({ args, options: {
|
|
94
|
+
'buoi-id': { type: 'string' }, loai: { type: 'string' }, ten: { type: 'string' },
|
|
95
|
+
'dry-run': { type: 'boolean' }, pretty: { type: 'boolean' },
|
|
96
|
+
}, strict: true, allowPositionals: true });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw new CliError(error.message, 2, 'USAGE');
|
|
100
|
+
}
|
|
101
|
+
const file = parsed.positionals[0];
|
|
102
|
+
const values = parsed.values;
|
|
103
|
+
if (!file || parsed.positionals.length !== 1 || !values['buoi-id'])
|
|
104
|
+
throw new CliError('Dùng: kf lms upload <file> --buoi-id <uuid>', 2, 'USAGE');
|
|
105
|
+
return { file, buoiId: values['buoi-id'], loai: values.loai,
|
|
106
|
+
ten: values.ten, dryRun: values['dry-run'], pretty: Boolean(values.pretty) };
|
|
107
|
+
}
|
|
108
|
+
async function liveOps(baseUrl, token) {
|
|
109
|
+
return parseOps(await request(baseUrl, token, 'ops'));
|
|
110
|
+
}
|
|
111
|
+
async function run(args) {
|
|
112
|
+
const command = args.shift();
|
|
113
|
+
if (command === 'login') {
|
|
114
|
+
if (args.length)
|
|
115
|
+
throw new CliError('kf login không nhận token qua tham số hoặc cờ', 2, 'USAGE');
|
|
116
|
+
const token = validToken((await hiddenToken()).trim());
|
|
117
|
+
const baseUrl = apiUrl();
|
|
118
|
+
const me = await request(baseUrl, token, 'me', {});
|
|
119
|
+
const label = labelFromMe(me);
|
|
120
|
+
await saveCredentials({ token, baseUrl, label });
|
|
121
|
+
print({ loai: 'ok', label, base_url: baseUrl }, false, token);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (command === 'logout') {
|
|
125
|
+
if (args.length)
|
|
126
|
+
throw new CliError('kf logout không nhận tham số', 2, 'USAGE');
|
|
127
|
+
await deleteCredentials();
|
|
128
|
+
print({ loai: 'ok', message: 'Đã xoá token trên máy. Thu hồi token trên trang Kết nối của tôi trong ops.' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const isHelp = !command || command === '--help' || command === '-h' || command === 'help';
|
|
132
|
+
let saved = null;
|
|
133
|
+
let baseUrl = '';
|
|
134
|
+
try {
|
|
135
|
+
saved = await readCredentials();
|
|
136
|
+
baseUrl = apiUrl(saved || undefined);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
// --help must still print the quickstart when the config is broken or KF_API_URL mismatches.
|
|
140
|
+
if (!isHelp)
|
|
141
|
+
throw error;
|
|
142
|
+
stdout.write(QUICKSTART);
|
|
143
|
+
stdout.write(`\n${error instanceof Error ? error.message : 'Cấu hình kf lỗi'}\n`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (command === 'status') {
|
|
147
|
+
if (args.length > 1 || (args.length === 1 && args[0] !== '--pretty'))
|
|
148
|
+
throw new CliError('kf status chỉ nhận --pretty', 2, 'USAGE');
|
|
149
|
+
const creds = requireCredentials(saved);
|
|
150
|
+
print(await request(baseUrl, creds.token, 'me', {}), args.includes('--pretty'), creds.token);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (isHelp) {
|
|
154
|
+
stdout.write(QUICKSTART);
|
|
155
|
+
if (saved) {
|
|
156
|
+
if (!/^kf_pat_[0-9a-f]{64}$/.test(saved.token)) {
|
|
157
|
+
stdout.write('\nToken không hợp lệ — mở terminal riêng và gõ `kf login`\n');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let ops;
|
|
161
|
+
try {
|
|
162
|
+
ops = await liveOps(baseUrl, saved.token);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof CliError && error.http === 401) {
|
|
166
|
+
stdout.write('\ntoken hết hạn/đã thu hồi — mở terminal riêng và gõ `kf login`\n');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
stdout.write('\nLive ops / Thao tác hiện có:\n');
|
|
172
|
+
for (const group of ['general', ...new Set(ops.filter((op) => op.id.includes('-')).map((op) => op.id.split('-')[0]))]) {
|
|
173
|
+
const items = ops.filter((op) => (op.id.includes('-') ? op.id.split('-')[0] : 'general') === group);
|
|
174
|
+
if (items.length)
|
|
175
|
+
stdout.write(` ${group}:\n${items.map((op) => ` ${op.id} [${op.risk}] ${op.tom_tat}`).join('\n')}\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
stdout.write('\nRun `kf login` in a separate terminal to list live operations.\n');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const creds = requireCredentials(saved);
|
|
183
|
+
if (command === 'ops') {
|
|
184
|
+
if (args.length > 1 || (args.length === 1 && args[0] !== '--pretty'))
|
|
185
|
+
throw new CliError('kf ops chỉ nhận --pretty', 2, 'USAGE');
|
|
186
|
+
print(await liveOps(baseUrl, creds.token), args.includes('--pretty'), creds.token);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (command === 'lms' && args[0] === 'upload') {
|
|
190
|
+
args.shift();
|
|
191
|
+
const opts = uploadArgs(args);
|
|
192
|
+
print(await upload(baseUrl, creds.token, opts), opts.pretty, creds.token);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const ops = await liveOps(baseUrl, creds.token);
|
|
196
|
+
const id = command === 'schema' ? args.shift() : command;
|
|
197
|
+
const op = ops.find((item) => item.id === id);
|
|
198
|
+
if (!op)
|
|
199
|
+
throw new CliError(`Không có thao tác: ${id || command}`, 2, 'USAGE');
|
|
200
|
+
if (command === 'schema') {
|
|
201
|
+
if (args.length > 1 || (args.length === 1 && args[0] !== '--pretty'))
|
|
202
|
+
throw new CliError('schema chỉ nhận --pretty', 2, 'USAGE');
|
|
203
|
+
print(op.input, args.includes('--pretty'), creds.token);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
207
|
+
stdout.write(`${operationHelp(op)}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const { body, pretty } = parseBody(op, args);
|
|
211
|
+
print(await request(baseUrl, creds.token, op.id, body), pretty, creds.token);
|
|
212
|
+
}
|
|
213
|
+
// Agents often pipe output (`kf --help | head`); a closed pipe is not an error.
|
|
214
|
+
stdout.on('error', (error) => { if (error.code === 'EPIPE')
|
|
215
|
+
process.exit(0); throw error; });
|
|
216
|
+
run(process.argv.slice(2)).catch((error) => {
|
|
217
|
+
const e = error instanceof CliError ? error : new CliError('Lỗi hệ thống', 5, 'INTERNAL');
|
|
218
|
+
stderr.write(`${redact(JSON.stringify({ error: e.message, code: e.code, http: e.http }))}\n`);
|
|
219
|
+
process.exitCode = e.exitCode;
|
|
220
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Credentials = {
|
|
2
|
+
token: string;
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
export declare const credentialsPath: () => string;
|
|
7
|
+
export declare function apiUrl(saved?: Credentials): string;
|
|
8
|
+
export declare function readCredentials(): Promise<Credentials | null>;
|
|
9
|
+
export declare function saveCredentials(value: Credentials): Promise<void>;
|
|
10
|
+
export declare function deleteCredentials(): Promise<void>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { CLI_NAME, DEFAULT_API_URL } from './kesflow.js';
|
|
5
|
+
import { CliError } from './api.js';
|
|
6
|
+
export const credentialsPath = () => join(homedir(), '.config', CLI_NAME, 'credentials.json');
|
|
7
|
+
export function apiUrl(saved) {
|
|
8
|
+
const normalize = (value) => {
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(value);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new CliError('KF_API_URL không hợp lệ', 2, 'CONFIG');
|
|
15
|
+
}
|
|
16
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
17
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) || url.username || url.password || url.search || url.hash)
|
|
18
|
+
throw new CliError('KF_API_URL không hợp lệ; chỉ localhost được dùng HTTP', 2, 'CONFIG');
|
|
19
|
+
return url.toString().replace(/\/$/, '');
|
|
20
|
+
};
|
|
21
|
+
const savedUrl = saved ? normalize(saved.baseUrl) : null;
|
|
22
|
+
const override = process.env.KF_API_URL;
|
|
23
|
+
const value = normalize(override === undefined ? savedUrl || DEFAULT_API_URL : override);
|
|
24
|
+
if (savedUrl && override !== undefined && value !== savedUrl)
|
|
25
|
+
throw new CliError('KF_API_URL khác nơi đã đăng nhập — mở terminal riêng và gõ `kf login` lại', 2, 'CONFIG');
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
export async function readCredentials() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(await readFile(credentialsPath(), 'utf8'));
|
|
31
|
+
if (!raw || typeof raw !== 'object')
|
|
32
|
+
throw new Error('credentials.json không hợp lệ');
|
|
33
|
+
const value = raw;
|
|
34
|
+
if (typeof value.token !== 'string' || typeof value.baseUrl !== 'string' || typeof value.label !== 'string') {
|
|
35
|
+
throw new Error('credentials.json không hợp lệ');
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT')
|
|
41
|
+
return null;
|
|
42
|
+
throw new CliError('credentials.json không hợp lệ; mở terminal riêng và gõ `kf login` lại', 2, 'CONFIG');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function saveCredentials(value) {
|
|
46
|
+
const path = credentialsPath();
|
|
47
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
48
|
+
await chmod(dirname(path), 0o700);
|
|
49
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(temp, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
|
|
52
|
+
await chmod(temp, 0o600);
|
|
53
|
+
await rename(temp, path);
|
|
54
|
+
await chmod(path, 0o600);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await rm(temp, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function deleteCredentials() {
|
|
61
|
+
await rm(credentialsPath(), { force: true });
|
|
62
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** KesFlow-specific defaults and agent help. The API/schema interpreter is in api.ts and ops.ts. */
|
|
2
|
+
export declare const CLI_NAME = "kf";
|
|
3
|
+
export declare const DEFAULT_API_URL = "https://kesflow.vn/api/agent/v1";
|
|
4
|
+
export declare const QUICKSTART = "AGENT QUICKSTART / B\u1EAET \u0110\u1EA6U CHO AGENT\n \u0110\u0103ng nh\u1EADp: b\u1EA3o ng\u01B0\u1EDDi d\u00F9ng m\u1EDF m\u1ED9t terminal ri\u00EAng v\u00E0 g\u00F5 `kf login`, r\u1ED3i d\u00E1n token v\u00E0o \u00F4 \u1EA9n (\u00F4 chat Claude Code v\u1EDBi `!` kh\u00F4ng c\u00F3 terminal n\u00EAn kh\u00F4ng d\u00F9ng \u0111\u01B0\u1EE3c). Agent kh\u00F4ng t\u1EF1 ch\u1EA1y `kf login`, kh\u00F4ng bao gi\u1EDD h\u1ECFi hay nh\u1EADn token trong chat.\n High-risk ops (--yes): ch\u1EC9 th\u00EAm --yes sau khi ng\u01B0\u1EDDi d\u00F9ng \u0111\u1ED3ng \u00FD r\u00F5 trong chat v\u1EDBi \u0111\u00FAng n\u1ED9i dung s\u1EBD l\u00E0m.\n kf ops List live operations / Li\u1EC7t k\u00EA thao t\u00E1c\n kf schema lms-doi-ten Inspect input JSON Schema\n kf lms-doi-ten --tep-id <uuid> --ten 'T\u00EAn m\u1EDBi'\n kf lms-dang-buoi --buoi-id <uuid> --gui-thu false --yes\n kf lms upload slide.html --buoi-id <uuid>\n\nCommands use registry IDs exactly: kf <op-id> [flags]. Output: one JSON line.\nExit: 0 OK, 2 usage, 3 auth/permission, 4 business, 5 network/server.\n";
|
package/dist/kesflow.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** KesFlow-specific defaults and agent help. The API/schema interpreter is in api.ts and ops.ts. */
|
|
2
|
+
export const CLI_NAME = 'kf';
|
|
3
|
+
export const DEFAULT_API_URL = 'https://kesflow.vn/api/agent/v1';
|
|
4
|
+
export const QUICKSTART = `AGENT QUICKSTART / BẮT ĐẦU CHO AGENT
|
|
5
|
+
Đăng nhập: bảo người dùng mở một terminal riêng và gõ \`kf login\`, rồi dán token vào ô ẩn (ô chat Claude Code với \`!\` không có terminal nên không dùng được). Agent không tự chạy \`kf login\`, không bao giờ hỏi hay nhận token trong chat.
|
|
6
|
+
High-risk ops (--yes): chỉ thêm --yes sau khi người dùng đồng ý rõ trong chat với đúng nội dung sẽ làm.
|
|
7
|
+
kf ops List live operations / Liệt kê thao tác
|
|
8
|
+
kf schema lms-doi-ten Inspect input JSON Schema
|
|
9
|
+
kf lms-doi-ten --tep-id <uuid> --ten 'Tên mới'
|
|
10
|
+
kf lms-dang-buoi --buoi-id <uuid> --gui-thu false --yes
|
|
11
|
+
kf lms upload slide.html --buoi-id <uuid>
|
|
12
|
+
|
|
13
|
+
Commands use registry IDs exactly: kf <op-id> [flags]. Output: one JSON line.
|
|
14
|
+
Exit: 0 OK, 2 usage, 3 auth/permission, 4 business, 5 network/server.
|
|
15
|
+
`;
|
package/dist/ops.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type JsonSchema = {
|
|
2
|
+
type?: string | string[];
|
|
3
|
+
properties?: Record<string, JsonSchema>;
|
|
4
|
+
required?: string[];
|
|
5
|
+
description?: string;
|
|
6
|
+
enum?: unknown[];
|
|
7
|
+
anyOf?: JsonSchema[];
|
|
8
|
+
default?: unknown;
|
|
9
|
+
};
|
|
10
|
+
export type Operation = {
|
|
11
|
+
id: string;
|
|
12
|
+
tom_tat: string;
|
|
13
|
+
risk: 'read' | 'write' | 'high';
|
|
14
|
+
roles: string[];
|
|
15
|
+
input: JsonSchema;
|
|
16
|
+
};
|
|
17
|
+
export declare function parseOps(value: unknown): Operation[];
|
|
18
|
+
export declare function parseBody(op: Operation, args: string[]): {
|
|
19
|
+
body: Record<string, unknown>;
|
|
20
|
+
pretty: boolean;
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
yes: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function operationHelp(op: Operation): string;
|
package/dist/ops.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CliError } from './api.js';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
export function parseOps(value) {
|
|
4
|
+
if (!Array.isArray(value) || !value.every((op) => op && typeof op.id === 'string' && /^[a-z][a-z0-9-]*$/.test(op.id)
|
|
5
|
+
&& typeof op.tom_tat === 'string' && ['read', 'write', 'high'].includes(op.risk)
|
|
6
|
+
&& op.input && typeof op.input === 'object'))
|
|
7
|
+
throw new CliError('/ops không hợp lệ', 5, 'BAD_OPS');
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function scalar(schema, raw) {
|
|
11
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
|
12
|
+
const choices = schema.anyOf || [schema];
|
|
13
|
+
const kind = types.find((type) => type !== 'null') || choices.flatMap((s) => Array.isArray(s.type) ? s.type : s.type ? [s.type] : []).find((type) => type !== 'null');
|
|
14
|
+
if (raw === 'null' && (types.includes('null') || choices.some((s) => s.type === 'null')))
|
|
15
|
+
return null;
|
|
16
|
+
if (kind === 'boolean') {
|
|
17
|
+
if (raw !== 'true' && raw !== 'false')
|
|
18
|
+
throw new CliError('Giá trị boolean phải là true hoặc false', 2, 'USAGE');
|
|
19
|
+
return raw === 'true';
|
|
20
|
+
}
|
|
21
|
+
if (kind === 'number' || kind === 'integer') {
|
|
22
|
+
const value = Number(raw);
|
|
23
|
+
if (!Number.isFinite(value) || (kind === 'integer' && !Number.isInteger(value)))
|
|
24
|
+
throw new CliError('Giá trị số không hợp lệ', 2, 'USAGE');
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (kind !== 'string')
|
|
28
|
+
throw new CliError('Thuộc tính phức tạp: dùng --json', 2, 'USAGE');
|
|
29
|
+
if (schema.enum && !schema.enum.includes(raw))
|
|
30
|
+
throw new CliError(`Giá trị phải là ${schema.enum.join('|')}`, 2, 'USAGE');
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
export function parseBody(op, args) {
|
|
34
|
+
let body = {};
|
|
35
|
+
let pretty = false, dryRun = false, yes = false;
|
|
36
|
+
const props = op.input.properties || {};
|
|
37
|
+
const options = {
|
|
38
|
+
pretty: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, yes: { type: 'boolean' }, json: { type: 'string' },
|
|
39
|
+
};
|
|
40
|
+
for (const name of Object.keys(props))
|
|
41
|
+
options[name.replaceAll('_', '-')] = { type: 'string' };
|
|
42
|
+
let tokens;
|
|
43
|
+
try {
|
|
44
|
+
tokens = parseArgs({ args, options, strict: true, allowPositionals: false, tokens: true }).tokens;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new CliError(error.message, 2, 'USAGE');
|
|
48
|
+
}
|
|
49
|
+
for (const token of tokens || []) {
|
|
50
|
+
if (token.kind !== 'option')
|
|
51
|
+
continue;
|
|
52
|
+
const flag = token.name;
|
|
53
|
+
if (flag === 'pretty') {
|
|
54
|
+
pretty = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (flag === 'dry-run') {
|
|
58
|
+
dryRun = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (flag === 'yes') {
|
|
62
|
+
yes = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (flag === 'json') {
|
|
66
|
+
const raw = token.value;
|
|
67
|
+
if (!raw)
|
|
68
|
+
throw new CliError('--json cần object', 2, 'USAGE');
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
|
72
|
+
throw new Error();
|
|
73
|
+
body = { ...body, ...parsed };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new CliError('--json cần object JSON hợp lệ', 2, 'USAGE');
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const key = flag.replaceAll('-', '_');
|
|
81
|
+
const raw = token.value;
|
|
82
|
+
if (raw === undefined)
|
|
83
|
+
throw new CliError(`Thiếu giá trị: --${flag}`, 2, 'USAGE');
|
|
84
|
+
body[key] = scalar(props[key], raw);
|
|
85
|
+
}
|
|
86
|
+
if (op.risk === 'high' && !yes)
|
|
87
|
+
throw new CliError('Thao tác rủi ro cao cần --yes', 2, 'USAGE');
|
|
88
|
+
if (yes && op.risk === 'high')
|
|
89
|
+
body.xac_nhan = true;
|
|
90
|
+
if (dryRun)
|
|
91
|
+
body.dry_run = true;
|
|
92
|
+
return { body, pretty, dryRun, yes };
|
|
93
|
+
}
|
|
94
|
+
export function operationHelp(op) {
|
|
95
|
+
const props = Object.entries(op.input.properties || {}).map(([name, schema]) => {
|
|
96
|
+
const required = op.input.required?.includes(name) ? ' (required)' : '';
|
|
97
|
+
const type = schema.type || schema.anyOf?.map((s) => s.type).filter(Boolean).join('|') || 'JSON';
|
|
98
|
+
return ` --${name.replaceAll('_', '-')} <${type}>${required}`;
|
|
99
|
+
}).join('\n');
|
|
100
|
+
return `${op.id} [${op.risk}] — ${op.tom_tat}\nRoles: ${op.roles.join(', ')}\n${props}\n --json '<object>' --dry-run --pretty${op.risk === 'high' ? ' --yes (required)' : ''}`;
|
|
101
|
+
}
|
package/dist/upload.d.ts
ADDED
package/dist/upload.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname } from 'node:path';
|
|
4
|
+
import { Transform } from 'node:stream';
|
|
5
|
+
import { CliError, request } from './api.js';
|
|
6
|
+
const mimeByExtension = {
|
|
7
|
+
'.pdf': { mime: 'application/pdf', loai: 'slide_pdf' },
|
|
8
|
+
'.html': { mime: 'text/html', loai: 'slide_html' },
|
|
9
|
+
'.htm': { mime: 'text/html', loai: 'slide_html' },
|
|
10
|
+
'.mp4': { mime: 'video/mp4', loai: 'record' },
|
|
11
|
+
'.webm': { mime: 'video/webm', loai: 'record' },
|
|
12
|
+
'.mov': { mime: 'video/quicktime', loai: 'record' },
|
|
13
|
+
};
|
|
14
|
+
async function step(name, run) {
|
|
15
|
+
try {
|
|
16
|
+
return await run();
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error instanceof CliError)
|
|
20
|
+
throw new CliError(`${name}: ${error.message}`, error.exitCode, error.code, error.http);
|
|
21
|
+
throw new CliError(`${name}: lỗi hệ thống`, 5, 'INTERNAL');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function dataOf(value, step) {
|
|
25
|
+
const envelope = value && typeof value === 'object' ? value : {};
|
|
26
|
+
const data = envelope.du_lieu;
|
|
27
|
+
if (!data || typeof data !== 'object')
|
|
28
|
+
throw new CliError(`${step}: thiếu du_lieu`, 5, 'BAD_RESPONSE');
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
export async function upload(baseUrl, token, opts) {
|
|
32
|
+
const fileType = mimeByExtension[extname(opts.file).toLowerCase()];
|
|
33
|
+
if (!fileType)
|
|
34
|
+
throw new CliError('Đuôi tệp không hỗ trợ (pdf, html, mp4, webm, mov)', 2, 'USAGE');
|
|
35
|
+
const loai = opts.loai || fileType.loai;
|
|
36
|
+
const allowed = fileType.mime.startsWith('video/') ? ['record', 'video_xem_truoc'] : [fileType.loai];
|
|
37
|
+
if (!allowed.includes(loai))
|
|
38
|
+
throw new CliError('--loai không khớp kiểu tệp', 2, 'USAGE');
|
|
39
|
+
let size;
|
|
40
|
+
try {
|
|
41
|
+
const info = await stat(opts.file);
|
|
42
|
+
if (!info.isFile())
|
|
43
|
+
throw new Error();
|
|
44
|
+
size = info.size;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new CliError('Không đọc được tệp', 2, 'FILE');
|
|
48
|
+
}
|
|
49
|
+
const createResponse = await step('tep-tao', () => request(baseUrl, token, 'lms-tep-tao', {
|
|
50
|
+
buoi_id: opts.buoiId, loai, ten: opts.ten || basename(opts.file), kich_thuoc: size, mime: fileType.mime,
|
|
51
|
+
...(opts.dryRun ? { dry_run: true } : {}),
|
|
52
|
+
}));
|
|
53
|
+
if (opts.dryRun)
|
|
54
|
+
return createResponse;
|
|
55
|
+
const created = dataOf(createResponse, 'tep-tao');
|
|
56
|
+
if (typeof created.id !== 'string' || typeof created.put_url !== 'string')
|
|
57
|
+
throw new CliError('tep-tao: thiếu id/put_url', 5, 'BAD_RESPONSE');
|
|
58
|
+
let sent = 0;
|
|
59
|
+
const meter = new Transform({
|
|
60
|
+
transform(chunk, _encoding, callback) {
|
|
61
|
+
sent += chunk.length;
|
|
62
|
+
process.stderr.write(`\r${sent}/${size} bytes`);
|
|
63
|
+
callback(null, chunk);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(created.put_url, {
|
|
68
|
+
method: 'PUT',
|
|
69
|
+
headers: { 'Content-Type': fileType.mime, 'Content-Length': String(size) },
|
|
70
|
+
body: createReadStream(opts.file).pipe(meter),
|
|
71
|
+
duplex: 'half',
|
|
72
|
+
signal: AbortSignal.timeout(120_000),
|
|
73
|
+
redirect: 'error',
|
|
74
|
+
});
|
|
75
|
+
process.stderr.write('\n');
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
throw new CliError(`PUT thất bại (${response.status})`, 5, 'UPLOAD_FAILED', response.status);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof CliError)
|
|
81
|
+
throw error;
|
|
82
|
+
throw new CliError('PUT thất bại', 5, 'UPLOAD_FAILED');
|
|
83
|
+
}
|
|
84
|
+
return step('tep-xac-nhan', () => request(baseUrl, token, 'lms-tep-xac-nhan', { buoi_id: opts.buoiId, tep_id: created.id }));
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kesflow/kf",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "KesFlow agent CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kf": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"SKILL.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
22
|
+
"prepack": "npm run build",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
24
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "22.10.2",
|
|
28
|
+
"typescript": "5.7.2"
|
|
29
|
+
},
|
|
30
|
+
"license": "UNLICENSED"
|
|
31
|
+
}
|