@twentyoz/alexx-cli 0.3.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.
@@ -0,0 +1,278 @@
1
+ // rec: 기록(rec) 조작 — ls/search/get/status/upload/watch/transcript/notes/summarize/chat/retry/
2
+ // rm(휴지통 이동)/restore/empty-trash
3
+ // 편의 alias(ls/get/upload/chat) 는 동일 add* 함수를 program 루트에도 등록.
4
+ // 각 명령은 withClient 로 등록 — handler/clientFromCommand 보일러플레이트는 action.ts 가 흡수.
5
+ import { withClient } from "../action.js";
6
+ import { AppError, printJson, printText, printJsonLine } from "../output.js";
7
+ import { prompt } from "../prompt.js";
8
+ import { parseDataOption } from "../data.js";
9
+ import { listAllRecs, recPath, recStatus, renderTranscriptText, requireResult, streamProgress, stripWaveform, } from "../recs.js";
10
+ // 서버(web/front/be recs-query.service.ts)의 페이지 상한 — 초과하면 서버가 조용히 클램프하므로
11
+ // stderr 로 경고해 "다 받았다"는 오인을 막는다. 값이 서버와 어긋나도 경고 문구만 부정확할 뿐
12
+ // 동작엔 영향 없음(실제 클램프는 서버가 한다).
13
+ const SERVER_MAX_LIMIT = 100;
14
+ function warnIfLimitCapped(limit, hint = "") {
15
+ if (!limit)
16
+ return;
17
+ const n = parseInt(limit, 10);
18
+ if (!Number.isFinite(n) || n <= SERVER_MAX_LIMIT)
19
+ return;
20
+ const tail = hint ? ` ${hint}` : "";
21
+ process.stderr.write(`warning: --limit ${n} exceeds the server max of ${SERVER_MAX_LIMIT}; capped to ${SERVER_MAX_LIMIT}.${tail}\n`);
22
+ }
23
+ // ── add*: 각 서브명령을 주어진 parent 에 등록 (rec 그룹 + alias 공용) ──
24
+ function addLs(parent) {
25
+ parent
26
+ .command("ls")
27
+ .description("List recordings (cursor pagination; --trash lists trashed ones)")
28
+ .option("--cursor <recId>", "start after this rec_id")
29
+ .option("--limit <n>", "page size")
30
+ .option("--tag <tagId>", "filter by tag")
31
+ .option("--trash", "list trashed recordings (collection=trash)")
32
+ .option("--all", "follow the cursor and collect everything")
33
+ .action(withClient(async (client, opts) => {
34
+ warnIfLimitCapped(opts.limit, opts.all
35
+ ? "Page size is capped; --all still fetches every record."
36
+ : "Pass --all to fetch every record.");
37
+ // 휴지통 목록은 collection=trash — 미지정(undefined)이면 서버 기본(활성 기록).
38
+ const collection = opts.trash ? "trash" : undefined;
39
+ if (!opts.all) {
40
+ printJson(await client.request("GET", "/recs", {
41
+ query: {
42
+ cursor: opts.cursor,
43
+ limit: opts.limit,
44
+ tag: opts.tag,
45
+ collection,
46
+ },
47
+ }));
48
+ return;
49
+ }
50
+ printJson(await listAllRecs(client, {
51
+ cursor: opts.cursor,
52
+ limit: opts.limit,
53
+ tag: opts.tag,
54
+ collection,
55
+ }));
56
+ }));
57
+ }
58
+ function addSearch(parent) {
59
+ parent
60
+ .command("search <query>")
61
+ .description("Full-text search recordings (GET /recs/search)")
62
+ .option("--limit <n>", "number of results")
63
+ .option("--tag <tagId>", "filter by tag")
64
+ .action(withClient(async (client, query, opts) => {
65
+ warnIfLimitCapped(opts.limit);
66
+ printJson(await client.request("GET", "/recs/search", {
67
+ query: { q: query, limit: opts.limit, tag: opts.tag },
68
+ }));
69
+ }));
70
+ }
71
+ function addGet(parent) {
72
+ parent
73
+ .command("get <id>")
74
+ .description("Recording detail (waveform omitted by default)")
75
+ .option("--waveform", "include result.waveform")
76
+ .action(withClient(async (client, id, opts) => {
77
+ const detail = await client.request("GET", recPath(id));
78
+ printJson(opts.waveform ? detail : stripWaveform(detail));
79
+ }));
80
+ }
81
+ function addStatus(parent) {
82
+ parent
83
+ .command("status <id>")
84
+ .description("Lightweight status/progress (for polling)")
85
+ .action(withClient(async (client, id, _opts) => {
86
+ printJson(recStatus(await client.request("GET", recPath(id))));
87
+ }));
88
+ }
89
+ function addUpload(parent) {
90
+ parent
91
+ .command("upload <file>")
92
+ .description("Upload audio (multipart)")
93
+ .option("--wait", "wait for completion, then print detail (progress on stderr)")
94
+ .option("--skip-summary", "skip AI summary")
95
+ .option("--template <id>", "summary template id")
96
+ .option("--timezone <tz>", "client timezone (default: system)")
97
+ .option("--force", "ignore tier limit")
98
+ .option("--context <text>", "background context (topic/attendees/proper nouns) fed to AI post-processing (max 2000 chars)")
99
+ .action(withClient(async (client, file, opts) => {
100
+ const tz = opts.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
101
+ const up = await client.upload("/upload", file, {
102
+ summary_tmpl_id: opts.template,
103
+ timezone: tz,
104
+ skip_summary: opts.skipSummary ? "true" : undefined,
105
+ force_upload: opts.force ? "true" : undefined,
106
+ context: opts.context,
107
+ });
108
+ if (!opts.wait) {
109
+ printJson(up);
110
+ return;
111
+ }
112
+ await streamProgress(client, up.rec_id, (e) => {
113
+ if (e.event !== "ping")
114
+ process.stderr.write(JSON.stringify(e) + "\n");
115
+ });
116
+ const detail = await client.request("GET", recPath(up.rec_id));
117
+ printJson(stripWaveform(detail));
118
+ }));
119
+ }
120
+ function addWatch(parent) {
121
+ parent
122
+ .command("watch <id>")
123
+ .description("Stream progress over SSE (JSON lines; ends on complete/error)")
124
+ .action(withClient(async (client, id, _opts) => {
125
+ await streamProgress(client, id, (e) => {
126
+ if (e.event !== "ping")
127
+ printJsonLine(e);
128
+ });
129
+ }));
130
+ }
131
+ function addTranscript(parent) {
132
+ parent
133
+ .command("transcript <id>")
134
+ .description("Transcript result (utterances)")
135
+ .option("--text", "print as `speaker: utterance` text")
136
+ .action(withClient(async (client, id, opts) => {
137
+ const result = requireResult(await client.request("GET", recPath(id)));
138
+ if (opts.text) {
139
+ printText(renderTranscriptText(result));
140
+ return;
141
+ }
142
+ printJson({
143
+ duration: result.duration,
144
+ speakers: result.speakers,
145
+ utterances: result.utterances,
146
+ });
147
+ }));
148
+ }
149
+ function addNotes(parent) {
150
+ parent
151
+ .command("notes <id>")
152
+ .description("List notes (user + AI summaries)")
153
+ .option("--ai", "only AI-generated summaries (is_ai_gen)")
154
+ .action(withClient(async (client, id, opts) => {
155
+ const notes = await client.request("GET", recPath(id, "/notes"));
156
+ printJson(opts.ai ? notes.filter((n) => n.is_ai_gen) : notes);
157
+ }));
158
+ }
159
+ function addSummarize(parent) {
160
+ parent
161
+ .command("summarize <id>")
162
+ .description("Regenerate the AI summary (POST /recs/:id/regenerate-summary)")
163
+ .option("--language <lang>", "language (default ko)")
164
+ .option("--template <id>", "summary template id")
165
+ .option("--data <json>", "raw JSON body (@file ok)")
166
+ .action(withClient(async (client, id, opts) => {
167
+ const body = { ...(parseDataOption(opts.data) ?? {}) };
168
+ if (opts.language)
169
+ body.language = opts.language;
170
+ if (opts.template)
171
+ body.template_id = opts.template;
172
+ printJson(await client.request("POST", recPath(id, "/regenerate-summary"), { body }));
173
+ }));
174
+ }
175
+ function addChat(parent) {
176
+ parent
177
+ .command("chat <id> [message...]")
178
+ .description("Chat with Alexx AI (POST /recs/:id/chat)")
179
+ .option("--thread <id>", "existing thread id")
180
+ .option("--language <lang>", "language (default ko)")
181
+ .option("--data <json>", "raw JSON body (@file ok)")
182
+ .action(withClient(async (client, id, messageParts, opts) => {
183
+ const body = { ...(parseDataOption(opts.data) ?? {}) };
184
+ const message = messageParts.join(" ").trim();
185
+ if (message)
186
+ body.message = message;
187
+ if (opts.thread)
188
+ body.thread_id = opts.thread;
189
+ if (opts.language)
190
+ body.language = opts.language;
191
+ if (!body.message) {
192
+ throw new AppError("usage", "A message is required.", {
193
+ hint: 'e.g. alexx rec chat <id> "summarize the key points" or --data \'{"message":"..."}\'',
194
+ exitCode: 2,
195
+ });
196
+ }
197
+ printJson(await client.request("POST", recPath(id, "/chat"), { body }));
198
+ }));
199
+ }
200
+ function addRetry(parent) {
201
+ parent
202
+ .command("retry <id>")
203
+ .description("Retry from the failed step (POST /recs/:id/retry)")
204
+ .action(withClient(async (client, id, _opts) => {
205
+ printJson(await client.request("POST", recPath(id, "/retry"), {}));
206
+ }));
207
+ }
208
+ /** 파괴적(복구 불가) 동작 앞의 확인 게이트 — 비-TTY 는 --yes 필수, TTY 는 y/N 프롬프트. */
209
+ async function confirmDestructive(yes, question, usageHint) {
210
+ if (yes)
211
+ return;
212
+ if (!process.stdin.isTTY) {
213
+ throw new AppError("usage", "--yes is required to delete.", {
214
+ hint: usageHint,
215
+ exitCode: 2,
216
+ });
217
+ }
218
+ const ans = await prompt(question);
219
+ if (!ans || !/^y(es)?$/i.test(ans)) {
220
+ throw new AppError("aborted", "Aborted.", { exitCode: 1 });
221
+ }
222
+ }
223
+ function addRm(parent) {
224
+ parent
225
+ .command("rm <id>")
226
+ .description("Move a recording to trash (restorable for 30 days; use --permanent to delete now)")
227
+ .option("--permanent", "delete immediately instead of trashing (irreversible)")
228
+ .option("--yes", "delete without confirmation (--permanent only)")
229
+ .action(withClient(async (client, id, opts) => {
230
+ // 기본(휴지통 이동)은 복구 가능하므로 확인 없이 진행. 확인 게이트는 --permanent 만.
231
+ if (!opts.permanent) {
232
+ printJson(await client.request("DELETE", recPath(id)));
233
+ return;
234
+ }
235
+ await confirmDestructive(opts.yes, `Permanently delete ${id}? This cannot be undone. [y/N]: `, "In non-interactive mode use `alexx rec rm <id> --permanent --yes`.");
236
+ printJson(await client.request("DELETE", recPath(id, "/permanent")));
237
+ }));
238
+ }
239
+ function addRestore(parent) {
240
+ parent
241
+ .command("restore <id>")
242
+ .description("Restore a trashed recording (POST /recs/:id/restore)")
243
+ .action(withClient(async (client, id, _opts) => {
244
+ printJson(await client.request("POST", recPath(id, "/restore"), {}));
245
+ }));
246
+ }
247
+ function addEmptyTrash(parent) {
248
+ parent
249
+ .command("empty-trash")
250
+ .description("Permanently delete every trashed recording (irreversible)")
251
+ .option("--yes", "delete without confirmation")
252
+ .action(withClient(async (client, opts) => {
253
+ await confirmDestructive(opts.yes, "Permanently delete everything in trash? [y/N]: ", "In non-interactive mode use `alexx rec empty-trash --yes`.");
254
+ printJson(await client.request("DELETE", "/recs/trash"));
255
+ }));
256
+ }
257
+ export function registerRec(program) {
258
+ const rec = program.command("rec").description("Work with recordings");
259
+ addLs(rec);
260
+ addSearch(rec);
261
+ addGet(rec);
262
+ addStatus(rec);
263
+ addUpload(rec);
264
+ addWatch(rec);
265
+ addTranscript(rec);
266
+ addNotes(rec);
267
+ addSummarize(rec);
268
+ addChat(rec);
269
+ addRetry(rec);
270
+ addRm(rec);
271
+ addRestore(rec);
272
+ addEmptyTrash(rec);
273
+ // 편의 alias (top-level): 자주 쓰는 명령을 rec 없이 호출.
274
+ addLs(program);
275
+ addGet(program);
276
+ addUpload(program);
277
+ addChat(program);
278
+ }
package/dist/config.js ADDED
@@ -0,0 +1,48 @@
1
+ // 설정/토큰 저장. ${XDG_CONFIG_HOME:-~/.config}/alexx/config.json, 권한 0600.
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync, } from "node:fs";
5
+ import { API_BASE_URL } from "./generated/endpoint.js";
6
+ function configDir() {
7
+ const xdg = process.env.XDG_CONFIG_HOME;
8
+ return xdg ? join(xdg, "alexx") : join(homedir(), ".config", "alexx");
9
+ }
10
+ function configPath() {
11
+ return join(configDir(), "config.json");
12
+ }
13
+ export function loadConfig() {
14
+ try {
15
+ return JSON.parse(readFileSync(configPath(), "utf8"));
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ export function saveConfig(cfg) {
22
+ const dir = configDir();
23
+ mkdirSync(dir, { recursive: true });
24
+ const p = configPath();
25
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
26
+ try {
27
+ chmodSync(p, 0o600);
28
+ }
29
+ catch {
30
+ /* best-effort */
31
+ }
32
+ }
33
+ export function clearConfig() {
34
+ try {
35
+ rmSync(configPath());
36
+ }
37
+ catch {
38
+ /* already gone */
39
+ }
40
+ }
41
+ /**
42
+ * base URL — 빌드 시 baked 된 고정값 (scripts/gen-endpoint.mjs).
43
+ * 런타임/CLI 플래그/환경변수로 변경 불가 — AI 가 임의로 dev/prod 를 건드리지 못하게 하는 가드레일.
44
+ * 환경 변경은 빌드 시점에만: `make build-cli env=local|dev|prod`.
45
+ */
46
+ export function resolveBaseUrl() {
47
+ return API_BASE_URL;
48
+ }
package/dist/data.js ADDED
@@ -0,0 +1,34 @@
1
+ // --data <json|@file> 파서: 인자 파싱 우회용 raw JSON 바디 입력.
2
+ import { readFileSync } from "node:fs";
3
+ import { AppError } from "./output.js";
4
+ export function parseDataOption(data) {
5
+ if (data === undefined)
6
+ return undefined;
7
+ let raw = data;
8
+ if (raw.startsWith("@")) {
9
+ // @- 는 stdin(fd 0) — "/dev/stdin" 경로는 Windows 에서 동작하지 않으므로 fd 를 직접 읽는다.
10
+ const src = raw === "@-" ? 0 : raw.slice(1);
11
+ try {
12
+ raw = readFileSync(src, "utf8");
13
+ }
14
+ catch (e) {
15
+ throw new AppError("usage", `Could not read --data file: ${raw === "@-" ? "stdin" : raw.slice(1)}`, {
16
+ hint: String(e instanceof Error ? e.message : e),
17
+ exitCode: 2,
18
+ });
19
+ }
20
+ }
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
24
+ throw new Error("must be a JSON object");
25
+ }
26
+ return parsed;
27
+ }
28
+ catch (e) {
29
+ throw new AppError("usage", `Failed to parse --data JSON: ${e instanceof Error ? e.message : e}`, {
30
+ hint: `e.g. --data '{"language":"en"}' or --data @body.json`,
31
+ exitCode: 2,
32
+ });
33
+ }
34
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,31 @@
1
+ // HTTP 상태코드 → 구조화 에러(AppError) 매핑. 코드/힌트/exit code 규약의 단일 정본.
2
+ import { AppError } from "./output.js";
3
+ const HTTP_ERRORS = {
4
+ 401: {
5
+ code: "unauthorized",
6
+ hint: "Sign in with `alexx auth login`, or set the ALEXX_TOKEN environment variable.",
7
+ exitCode: 3,
8
+ fallbackMessage: "Authentication required",
9
+ },
10
+ 403: {
11
+ code: "forbidden",
12
+ hint: "Check your permissions or tier limit (`alexx api GET /tier/usage`).",
13
+ },
14
+ 404: {
15
+ code: "not_found",
16
+ hint: "Check for a valid id with `alexx rec ls`.",
17
+ },
18
+ 409: {
19
+ code: "conflict",
20
+ hint: "It may already be processing. Check with `alexx rec status <id>`.",
21
+ },
22
+ };
23
+ export function httpError(status, message) {
24
+ const spec = HTTP_ERRORS[status];
25
+ if (!spec)
26
+ return new AppError(`http_${status}`, message);
27
+ return new AppError(spec.code, message || spec.fallbackMessage || message, {
28
+ hint: spec.hint,
29
+ exitCode: spec.exitCode,
30
+ });
31
+ }
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by scripts/gen-endpoint.mjs — do not edit. (build-time baked endpoint)
2
+ export const API_BASE_URL = "https://alexx.app/api";
3
+ export const BUILD_ENV = "prod";
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Alexx CLI (alexx) — AI 에이전트를 위한 음성 분석 앱 조작 도구.
3
+ // 기본 출력: 머신리더블 JSON. 에러: {"error":{code,message,hint}} → stderr.
4
+ import { Command } from "commander";
5
+ import { registerAuth } from "./commands/auth.js";
6
+ import { registerRec } from "./commands/rec.js";
7
+ import { registerMeta } from "./commands/meta.js";
8
+ import { registerApi } from "./commands/api.js";
9
+ import { setPretty, fail } from "./output.js";
10
+ import { API_BASE_URL, BUILD_ENV } from "./generated/endpoint.js";
11
+ import { VERSION } from "./version.js";
12
+ const program = new Command();
13
+ program
14
+ .name("alexx")
15
+ .description("Alexx CLI — drive the voice-analysis app (Front API). Output is JSON; errors are structured JSON on stderr.\n" +
16
+ "Auth: `alexx auth login` or ALEXX_TOKEN env. Search→Fetch→Act: `rec ls`/`rec search` → `rec get`/`rec transcript` → `rec chat`/`rec summarize`.\n" +
17
+ `endpoint: ${API_BASE_URL} (env=${BUILD_ENV}, baked at build time — not changeable at runtime)`)
18
+ .version(VERSION)
19
+ .option("--token <jwt>", "access token to use (env ALEXX_TOKEN)")
20
+ .option("--pretty", "pretty-print JSON output")
21
+ .enablePositionalOptions();
22
+ program.hook("preAction", (_thisCommand, actionCommand) => {
23
+ const g = actionCommand.optsWithGlobals();
24
+ setPretty(!!g.pretty);
25
+ });
26
+ registerAuth(program);
27
+ registerRec(program);
28
+ registerMeta(program);
29
+ registerApi(program);
30
+ program.parseAsync(process.argv).catch((e) => fail(e));
package/dist/output.js ADDED
@@ -0,0 +1,59 @@
1
+ // 출력 + 에러 규약. AI 친화: 기본 JSON, 에러는 What+Why+Hint 구조로 stderr, 안정적 exit code.
2
+ /** exit code 규약: 0 성공 / 1 일반 / 2 사용법 / 3 인증 / 4 not found */
3
+ export class AppError extends Error {
4
+ code;
5
+ hint;
6
+ exitCode;
7
+ constructor(code, message, opts = {}) {
8
+ super(message);
9
+ this.code = code;
10
+ this.hint = opts.hint;
11
+ this.exitCode = opts.exitCode ?? 1;
12
+ }
13
+ }
14
+ let pretty = false;
15
+ export function setPretty(v) {
16
+ pretty = v;
17
+ }
18
+ export function printJson(data) {
19
+ // 빈 body(204/empty 200) 응답은 undefined → JSON.stringify 가 "undefined"(비-JSON) 를
20
+ // 내보내 stdout 파싱이 깨진다. null 로 보정해 항상 유효한 JSON 출력.
21
+ const safe = data === undefined ? null : data;
22
+ process.stdout.write(JSON.stringify(safe, null, pretty ? 2 : 0) + "\n");
23
+ }
24
+ export function printText(s) {
25
+ process.stdout.write(s.endsWith("\n") ? s : s + "\n");
26
+ }
27
+ /** 한 줄 JSON 스트림(watch SSE 용) — pretty 무관하게 항상 단일 라인. */
28
+ export function printJsonLine(data) {
29
+ process.stdout.write(JSON.stringify(data) + "\n");
30
+ }
31
+ export function fail(err) {
32
+ let payload;
33
+ let exitCode = 1;
34
+ if (err instanceof AppError) {
35
+ payload = { code: err.code, message: err.message };
36
+ if (err.hint)
37
+ payload.hint = err.hint;
38
+ exitCode = err.exitCode;
39
+ }
40
+ else if (err instanceof Error) {
41
+ payload = { code: "error", message: err.message };
42
+ }
43
+ else {
44
+ payload = { code: "error", message: String(err) };
45
+ }
46
+ process.stderr.write(JSON.stringify({ error: payload }) + "\n");
47
+ process.exit(exitCode);
48
+ }
49
+ /** commander action 래퍼 — 예외를 구조화 에러로 변환하고 종료. */
50
+ export function handler(fn) {
51
+ return async (...args) => {
52
+ try {
53
+ await fn(...args);
54
+ }
55
+ catch (e) {
56
+ fail(e);
57
+ }
58
+ };
59
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,27 @@
1
+ // TTY 대화형 입력 폴백 (비대화형이 기본 — env/flag 우선). 비-TTY 면 undefined.
2
+ import { createInterface } from "node:readline";
3
+ export async function prompt(label, mask = false) {
4
+ if (!process.stdin.isTTY)
5
+ return undefined;
6
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
7
+ // 비밀번호 마스킹: 출력 write 를 가로채 '*' 만 표시.
8
+ if (mask) {
9
+ const out = process.stdout;
10
+ const orig = out.write.bind(out);
11
+ rl._writeToOutput = (s) => {
12
+ if (s.includes(label))
13
+ orig(s);
14
+ else
15
+ orig("*".repeat(Math.max(0, s.replace(/\r?\n/g, "").length)));
16
+ };
17
+ }
18
+ try {
19
+ const answer = await new Promise((resolve) => rl.question(label, resolve));
20
+ if (mask)
21
+ process.stdout.write("\n");
22
+ return answer.trim() || undefined;
23
+ }
24
+ finally {
25
+ rl.close();
26
+ }
27
+ }
package/dist/recs.js ADDED
@@ -0,0 +1,92 @@
1
+ // 기록(rec) 조회·가공 공용 로직 — 출력(stdout)은 commands/rec.ts 가 정하고 여기서는 값만 만든다.
2
+ import { AppError } from "./output.js";
3
+ /**
4
+ * `/recs/<id><suffix>` 경로. id 는 외부 입력이라 인코딩한다 — URL 파서가 `..` 를 정규화해
5
+ * `rec rm ../auth/account` 가 `DELETE /auth/account`(회원 탈퇴)로 새는 것을 막는다.
6
+ */
7
+ export function recPath(id, suffix = "") {
8
+ return `/recs/${encodeURIComponent(id)}${suffix}`;
9
+ }
10
+ export function stripWaveform(detail) {
11
+ if (detail.result && "waveform" in detail.result) {
12
+ delete detail.result.waveform;
13
+ }
14
+ return detail;
15
+ }
16
+ /** SSE 진행 이벤트를 정규화해 콜백으로 전달. complete/error 에서 종료. */
17
+ export async function streamProgress(client, id, onEvent) {
18
+ await client.sse(recPath(id, "/stream"), (ev) => {
19
+ let data;
20
+ try {
21
+ data = JSON.parse(ev.data);
22
+ }
23
+ catch {
24
+ data = ev.data;
25
+ }
26
+ const payload = data && typeof data === "object" ? data : { data };
27
+ onEvent({ event: ev.type, ...payload });
28
+ return ev.type === "complete" || ev.type === "error";
29
+ });
30
+ }
31
+ /** cursor 를 따라가며 전체 목록 수집 (rec_id 중복 제거). */
32
+ export async function listAllRecs(client, query) {
33
+ const acc = [];
34
+ const seen = new Set();
35
+ let cursor = query.cursor;
36
+ for (;;) {
37
+ const page = await client.request("GET", "/recs", {
38
+ query: { ...query, cursor },
39
+ });
40
+ if (!page.length)
41
+ break;
42
+ let added = 0;
43
+ for (const r of page) {
44
+ if (!seen.has(r.rec_id)) {
45
+ seen.add(r.rec_id);
46
+ acc.push(r);
47
+ added += 1;
48
+ }
49
+ }
50
+ // 새로 추가된 게 없으면(전부 중복) 더 진행해도 의미 없음 → 종료. cursor 정체와
51
+ // 함께 무한루프/부분누락을 방지.
52
+ if (added === 0)
53
+ break;
54
+ const next = page[page.length - 1].rec_id;
55
+ if (next === cursor)
56
+ break;
57
+ cursor = next;
58
+ }
59
+ return acc;
60
+ }
61
+ /** 폴링용 경량 상태 투영. */
62
+ export function recStatus(d) {
63
+ return {
64
+ rec_id: d.rec_id,
65
+ filename: d.filename,
66
+ status: d.status,
67
+ progress: d.progress,
68
+ error: d.error,
69
+ completed_at: d.completed_at,
70
+ has_result: d.has_result ?? !!d.result,
71
+ };
72
+ }
73
+ /** 전사 결과. 아직 완료 전이면 not_ready. */
74
+ export function requireResult(d) {
75
+ if (d.status !== "completed" || !d.result) {
76
+ throw new AppError("not_ready", `No transcript available (status=${d.status}).`, {
77
+ hint: "Check progress with `alexx rec status <id>`.",
78
+ });
79
+ }
80
+ return d.result;
81
+ }
82
+ /** `화자: 발화` 텍스트. */
83
+ export function renderTranscriptText(result) {
84
+ const speakers = result.speakers ?? {};
85
+ return result.utterances
86
+ .map((u) => {
87
+ const key = u.cluster ?? u.speaker;
88
+ const name = speakers[key]?.name ?? speakers[u.speaker]?.name ?? u.speaker;
89
+ return `${name}: ${u.text}`;
90
+ })
91
+ .join("\n");
92
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // API 응답 미러링 (web/front/fe/src/types/index.ts 기준 — CLI-local 사본).
2
+ export {};
@@ -0,0 +1,3 @@
1
+ // 패키지 버전 단일 출처 — package.json 은 npm 발행본에도 항상 포함된다 (src/ 와 dist/ 모두 ../package.json).
2
+ import { createRequire } from "node:module";
3
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@twentyoz/alexx-cli",
3
+ "version": "0.3.0",
4
+ "description": "Alexx CLI (alexx) — AI 에이전트를 위한 음성 분석 앱 조작 도구. npm 발행본은 운영 endpoint baked.",
5
+ "type": "module",
6
+ "bin": {
7
+ "alexx": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "license": "UNLICENSED",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "gen": "node scripts/gen-endpoint.mjs",
22
+ "build": "npm run gen && tsc -p tsconfig.json",
23
+ "dev": "npm run gen && tsx src/index.ts",
24
+ "start": "node dist/index.js",
25
+ "test": "tsx --test test/*.test.ts",
26
+ "prepare": "npm run build",
27
+ "prepublishOnly": "node scripts/assert-prod.mjs"
28
+ },
29
+ "dependencies": {
30
+ "commander": "15.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "22.20.1",
34
+ "tsx": "4.23.1",
35
+ "typescript": "5.9.3"
36
+ }
37
+ }