skyloom 1.23.0 → 1.25.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 (66) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/README.md +6 -1
  3. package/dist/cli/main.js +71 -22
  4. package/dist/cli/main.js.map +1 -1
  5. package/dist/cli/tui.d.ts.map +1 -1
  6. package/dist/cli/tui.js +0 -6
  7. package/dist/cli/tui.js.map +1 -1
  8. package/dist/core/commands.d.ts.map +1 -1
  9. package/dist/core/commands.js +10 -0
  10. package/dist/core/commands.js.map +1 -1
  11. package/dist/core/diff.d.ts.map +1 -1
  12. package/dist/core/diff.js +0 -1
  13. package/dist/core/diff.js.map +1 -1
  14. package/dist/core/evolve.d.ts.map +1 -1
  15. package/dist/core/evolve.js +0 -9
  16. package/dist/core/evolve.js.map +1 -1
  17. package/dist/core/graph.d.ts +1 -1
  18. package/dist/core/graph.d.ts.map +1 -1
  19. package/dist/core/graph.js +1 -1
  20. package/dist/core/graph.js.map +1 -1
  21. package/dist/core/llm.d.ts +3 -0
  22. package/dist/core/llm.d.ts.map +1 -1
  23. package/dist/core/llm.js +4 -27
  24. package/dist/core/llm.js.map +1 -1
  25. package/dist/core/sandbox.d.ts.map +1 -1
  26. package/dist/core/sandbox.js +0 -2
  27. package/dist/core/sandbox.js.map +1 -1
  28. package/dist/core/schemas.d.ts +1 -1
  29. package/dist/core/schemas.d.ts.map +1 -1
  30. package/dist/core/schemas.js +1 -23
  31. package/dist/core/schemas.js.map +1 -1
  32. package/dist/core/skill.d.ts.map +1 -1
  33. package/dist/core/skill.js +0 -1
  34. package/dist/core/skill.js.map +1 -1
  35. package/dist/gateway/qr.d.ts +8 -0
  36. package/dist/gateway/qr.d.ts.map +1 -0
  37. package/dist/gateway/qr.js +22 -0
  38. package/dist/gateway/qr.js.map +1 -0
  39. package/dist/gateway/setup.d.ts +57 -0
  40. package/dist/gateway/setup.d.ts.map +1 -0
  41. package/dist/gateway/setup.js +127 -0
  42. package/dist/gateway/setup.js.map +1 -0
  43. package/dist/tools/computer.d.ts.map +1 -1
  44. package/dist/tools/computer.js.map +1 -1
  45. package/dist/web/server.d.ts.map +1 -1
  46. package/dist/web/server.js +0 -2
  47. package/dist/web/server.js.map +1 -1
  48. package/eslint.config.js +62 -0
  49. package/package.json +2 -1
  50. package/src/cli/main.ts +65 -22
  51. package/src/cli/tui.ts +0 -2
  52. package/src/core/commands.ts +10 -0
  53. package/src/core/diff.ts +0 -1
  54. package/src/core/evolve.ts +0 -6
  55. package/src/core/graph.ts +1 -1
  56. package/src/core/llm.ts +4 -33
  57. package/src/core/sandbox.ts +1 -4
  58. package/src/core/schemas.ts +1 -25
  59. package/src/core/skill.ts +0 -1
  60. package/src/gateway/qr.ts +20 -0
  61. package/src/gateway/setup.ts +145 -0
  62. package/src/tools/computer.ts +2 -2
  63. package/src/web/server.ts +0 -3
  64. package/tests/channel_setup.test.ts +88 -0
  65. package/tests/factory.test.ts +56 -0
  66. package/tests/pipelines.test.ts +118 -0
package/src/cli/main.ts CHANGED
@@ -10,9 +10,8 @@ import { createSystemContext, orchestrateTask } from "../core/factory";
10
10
  import { loadConfig, USER_CONFIG_DIR } from "../core/config";
11
11
  import { listProviders, modelsFor, providerLabel, validateModel } from "../core/catalog";
12
12
  import { agentTheme } from "../core/theme";
13
- import { classify } from "../core/router";
14
13
  import { InteractiveMode, ModeController } from "./mode";
15
- import { readLine, renderPalette, StreamRenderer, stripMarkdown } from "./tui";
14
+ import { readLine, renderPalette, StreamRenderer } from "./tui";
16
15
  import { loomChat } from "./loom_chat";
17
16
 
18
17
  const MODE = new ModeController();
@@ -38,6 +37,8 @@ program.command("mcp").action(() => { import("../core/mcp_server").then(m => m.s
38
37
  program.command("gateway").description("Run the channel gateway (Feishu / WeCom / QQ)")
39
38
  .option("-p,--port <p>", "port", "8848")
40
39
  .action((o: { port?: string }) => { import("../gateway/gateway").then(m => m.startGateway({ port: parseInt(o.port || "8848") })); });
40
+ program.command("channels").description("Configure a chat channel (Feishu / WeCom / QQ) with QR shortcuts")
41
+ .action(async () => { await channelsWizard(); });
41
42
  program.command("config").action(() => { const c = loadConfig(); process.stdout.write(chalk.cyan("\nConfig: ") + USER_CONFIG_DIR + "\n"); for (const [n, a] of Object.entries(c.agents || {})) process.stdout.write(` ${chalk.bold(n)}: ${(a as any).model || "default"}\n`); });
42
43
  program.command("init").action(() => { if (!fs.existsSync(USER_CONFIG_DIR)) fs.mkdirSync(USER_CONFIG_DIR, { recursive: true }); process.stdout.write(chalk.green("✓ ") + USER_CONFIG_DIR + "\n"); });
43
44
  program.command("apikey").description("Manage API keys (persisted to ~/.skyloom/config.yaml)")
@@ -71,18 +72,6 @@ function welcome(agent: any) {
71
72
  process.stdout.write(chalk.dim(" /help for commands · /quit to exit\n\n"));
72
73
  }
73
74
 
74
- function statusBar(agent: any, ctx: any): string {
75
- try {
76
- const cu = agent.contextUsage();
77
- const pct = cu.pct || 0;
78
- const bar = pct < 50 ? chalk.green : pct < 80 ? chalk.yellow : chalk.red;
79
- const filled = Math.round(pct / 10);
80
- const ctxBar = `${bar("█".repeat(filled) + "░".repeat(10 - filled))} ${pct}%`;
81
- const cost = formatCost(ctx.llm.getTotalCost());
82
- return chalk.dim(`${ctxBar} · ${cost} · ${cu.model || "?"}`);
83
- } catch { return ""; }
84
- }
85
-
86
75
  function formatCost(c: number): string {
87
76
  if (c >= 1) return chalk.yellow(`$${c.toFixed(2)}`);
88
77
  if (c >= 0.01) return chalk.yellow(`$${c.toFixed(4)}`);
@@ -117,11 +106,6 @@ async function streamResponse(agent: any, input: string): Promise<void> {
117
106
  const header = () => { if (!headerShown) { out.write("\n " + chalk.bold.hex(theme.hex)(`${theme.symbol} ${theme.kanji}`) + chalk.hex(theme.hex)(` ${theme.name}`) + "\n\n"); headerShown = true; } };
118
107
  const endBlock = () => { if (renderer) { renderer.flush(); renderer = null; out.write("\n"); } };
119
108
 
120
- // All content passes through this cleaner before display
121
- const writeClean = (text: string) => {
122
- if (renderer) renderer.write(text);
123
- };
124
-
125
109
  // ── Ctrl-C interrupts this turn (keeps partial output); a 2nd Ctrl-C exits. ──
126
110
  const controller = new AbortController();
127
111
  let interrupted = false;
@@ -275,6 +259,65 @@ async function setupWizard(): Promise<{ provider: string; key: string; model: st
275
259
  return { provider: prov.id, key: key.trim(), model };
276
260
  }
277
261
 
262
+ /* ═══════════════════════════════════════
263
+ Channel setup wizard (sky channels)
264
+ ═══════════════════════════════════════ */
265
+ async function channelsWizard(): Promise<void> {
266
+ const { CHANNEL_SETUP, SETUP_CHANNEL_IDS, callbackUrl, saveChannelConfig, missingRequired } = require("../gateway/setup");
267
+ const { renderQR } = require("../gateway/qr");
268
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
269
+ const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
270
+
271
+ try {
272
+ process.stdout.write("\n" + chalk.cyan(" ✦ 渠道接入向导 · sky channels ✦\n\n"));
273
+ process.stdout.write(chalk.dim(" 选择要配置的聊天软件:\n\n"));
274
+ SETUP_CHANNEL_IDS.forEach((id: string, i: number) => {
275
+ process.stdout.write(chalk.dim(` ${i + 1}. ${CHANNEL_SETUP[id].name}\n`));
276
+ });
277
+ const choice = await ask(chalk.cyan(`\n 编号 (1-${SETUP_CHANNEL_IDS.length}, q 退出): `));
278
+ if (choice.trim().toLowerCase() === "q") return;
279
+ const idx = parseInt(choice) - 1;
280
+ if (isNaN(idx) || idx < 0 || idx >= SETUP_CHANNEL_IDS.length) { process.stdout.write(chalk.dim(" 已取消\n")); return; }
281
+ const spec = CHANNEL_SETUP[SETUP_CHANNEL_IDS[idx]];
282
+
283
+ // Steps + QR to the platform console.
284
+ process.stdout.write("\n" + chalk.bold(` 配置 ${spec.name}\n\n`));
285
+ spec.steps.forEach((s: string, i: number) => process.stdout.write(chalk.dim(` ${i + 1}. ${s}\n`)));
286
+ process.stdout.write(chalk.dim(`\n 📱 扫码打开管理后台: `) + chalk.cyan(spec.consoleUrl) + "\n");
287
+ const consoleQR = renderQR(spec.consoleUrl);
288
+ if (consoleQR) process.stdout.write("\n" + consoleQR.split("\n").map((l: string) => " " + l).join("\n") + "\n");
289
+ if (spec.docsUrl) process.stdout.write(chalk.dim(` 📖 文档: ${spec.docsUrl}\n`));
290
+
291
+ // Collect credential fields.
292
+ process.stdout.write("\n" + chalk.dim(" 逐项填入凭据(回车跳过可选项):\n\n"));
293
+ const values: Record<string, string> = {};
294
+ for (const f of spec.fields) {
295
+ const req = f.required ? chalk.red("*") : chalk.dim("(可选)");
296
+ if (f.hint) process.stdout.write(chalk.dim(` ↳ ${f.hint}\n`));
297
+ const v = await ask(chalk.cyan(` ${f.label} ${req}: `));
298
+ if (v.trim()) values[f.key] = v.trim();
299
+ }
300
+ const missing = missingRequired(spec.id, values);
301
+ if (missing.length) {
302
+ process.stdout.write(chalk.yellow(`\n ⚠ 缺少必填项: ${missing.join(", ")} — 已保存现有项,可再次运行补全。\n`));
303
+ }
304
+
305
+ const cfgPath = saveChannelConfig(spec.id, values);
306
+ process.stdout.write(chalk.green(`\n ✓ 已保存到 ${cfgPath} 的 channels.${spec.id}\n`));
307
+
308
+ // Callback URL + QR to paste into the platform console.
309
+ const base = (await ask(chalk.cyan("\n 你的网关公网地址(如 https://bot.example.com,回车用 http://localhost:8848): "))).trim()
310
+ || "http://localhost:8848";
311
+ const cb = callbackUrl(base, spec.id);
312
+ process.stdout.write(chalk.dim("\n 把下面的回调 URL 填入平台后台的事件/接收配置:\n ") + chalk.cyan(cb) + "\n");
313
+ const cbQR = renderQR(cb);
314
+ if (cbQR) process.stdout.write("\n" + cbQR.split("\n").map((l: string) => " " + l).join("\n") + "\n");
315
+ process.stdout.write(chalk.dim(`\n 完成后运行 sky gateway 启动网关。\n\n`));
316
+ } finally {
317
+ rl.close();
318
+ }
319
+ }
320
+
278
321
  async function chat(agentName: string, modelOverride?: string, classic?: boolean): Promise<void> {
279
322
  const haveKey = checkApiKeys();
280
323
  if (!haveKey) {
@@ -313,7 +356,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
313
356
 
314
357
  // Wire up security approval — prompt user for HIGH/CRITICAL operations
315
358
  try {
316
- const { getSecurity, DangerLevel, PERMISSION_MODE_ALIASES } = require("../core/security");
359
+ const { getSecurity, PERMISSION_MODE_ALIASES } = require("../core/security");
317
360
  const sec = getSecurity();
318
361
  // Honor a configured permission mode (config.yaml cli.approvalMode), mapped
319
362
  // through the same aliases as /perm.
@@ -344,7 +387,6 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
344
387
  return; // loomChat exits the process itself
345
388
  }
346
389
 
347
- // eslint-disable-next-line prefer-const
348
390
  let currentAgent = agent; // mutable for agent switching
349
391
  let lastSessions: any[] = []; // index→session map for /resume <n>
350
392
  welcome(agent);
@@ -508,6 +550,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
508
550
  }
509
551
  if (cmdL.startsWith("/task ")) { const g = inp.slice(6); process.stdout.write(chalk.cyan("\n ✦ " + g + "\n\n")); await runTask(g); continue; }
510
552
  if (cmdL === "/setup") { const r = await setupWizard(); if (r) process.stdout.write(chalk.green(` ${r.provider} · ${r.model} — Ready!\n`)); continue; }
553
+ if (cmdL === "/channels") { await channelsWizard(); continue; }
511
554
  if (cmdL === "/model" || cmdL.startsWith("/model ")) {
512
555
  const { setAgentModel, setUnifiedModel, clearAgentModel, setAgentApiKey, describeAgentLLM } = require("../core/model_config");
513
556
  const cfg = (ctx as any).config;
@@ -537,7 +580,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
537
580
  continue;
538
581
  }
539
582
  if (cmdL === "/models" || cmdL.startsWith("/models ")) {
540
- const { listProviders, modelsFor, providerLabel, allModels } = require("../core/catalog");
583
+ const { listProviders, modelsFor, providerLabel } = require("../core/catalog");
541
584
  const args = inp.split(/\s+/).slice(1);
542
585
  const filter = args[0]?.toLowerCase() || "";
543
586
  process.stdout.write(chalk.bold("\n ✦ 模型目录 · Model Catalog\n"));
package/src/cli/tui.ts CHANGED
@@ -14,8 +14,6 @@ import chalk from "chalk";
14
14
  import { agentTheme, PALETTE } from "../core/theme";
15
15
  import { registry } from "../core/commands";
16
16
 
17
- const TUI_VERSION = (() => { try { return require("../../package.json").version; } catch { return ""; } })();
18
-
19
17
  export interface TUIContext {
20
18
  agent: any;
21
19
  agents: Map<string, any>;
@@ -482,6 +482,16 @@ export const BUILTIN_COMMANDS: CommandInfo[] = [
482
482
  takesArgs: false,
483
483
  source: 'builtin',
484
484
  },
485
+ {
486
+ name: 'channels',
487
+ aliases: [],
488
+ description: 'Configure a chat channel (Feishu / WeCom / QQ) with QR shortcuts',
489
+ label: '渠道接入向导(飞书/企业微信/QQ · 含二维码)',
490
+ category: 'config',
491
+ hints: [],
492
+ takesArgs: false,
493
+ source: 'builtin',
494
+ },
485
495
  {
486
496
  name: 'apikey',
487
497
  aliases: [],
package/src/core/diff.ts CHANGED
@@ -60,7 +60,6 @@ export function unifiedDiff(oldStr: string, newStr: string, opts: DiffOptions =
60
60
  // Context window bounds.
61
61
  const ctxStart = Math.max(0, pre - context);
62
62
  const oldCtxAfterStart = oldLines.length - suf;
63
- const newCtxAfterStart = newLines.length - suf;
64
63
  const oldCtxAfter = oldLines.slice(oldCtxAfterStart, oldCtxAfterStart + context);
65
64
  const leading = oldLines.slice(ctxStart, pre);
66
65
 
@@ -121,12 +121,6 @@ export function analyzeFailures(
121
121
  }
122
122
  }
123
123
 
124
- // General rule: if failure rate > 20%, suggest self-review
125
- const recentExperiences = recent.filter(e => {
126
- try { return new Date(e.lastSeen).getTime() > Date.now() - 3 * 86400000; }
127
- catch { return false; }
128
- });
129
-
130
124
  // Deduplicate suggestions
131
125
  const seen = new Set<string>();
132
126
  const uniqueDiffs = suggestedDiffs.filter(d => {
package/src/core/graph.ts CHANGED
@@ -140,7 +140,7 @@ const RELATION_PATTERNS: Array<[RegExp, string]> = [
140
140
  [/(\w+) (?:file|path|文件|路径) (?:在|为|at) (.+?)(?:[。,,.\n]|$)/gi, "located_at"],
141
141
  ];
142
142
 
143
- export function extractFacts(text: string, agent: string): Array<[string, string, string]> {
143
+ export function extractFacts(text: string, _agent: string): Array<[string, string, string]> {
144
144
  const facts: Array<[string, string, string]> = [];
145
145
  for (const [pattern, pred] of RELATION_PATTERNS) {
146
146
  let match;
package/src/core/llm.ts CHANGED
@@ -355,37 +355,6 @@ const FALLBACK_CHAINS: Map<string, string[]> = new Map([
355
355
  /**
356
356
  * HTTP status codes that are considered transient errors (worth retrying).
357
357
  */
358
- const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
359
-
360
- /**
361
- * Check if an exception is worth retrying.
362
- */
363
- function isTransientError(err: unknown): boolean {
364
- if (err instanceof Error) {
365
- const status =
366
- (err as any).status_code || (err as any).http_status || 0;
367
- if (status && RETRYABLE_STATUSES.has(status)) {
368
- return true;
369
- }
370
-
371
- if (err.name === "TimeoutError") {
372
- return true;
373
- }
374
-
375
- const errName = err.constructor.name.toLowerCase();
376
- return [
377
- "ratelimiterror",
378
- "apitimeouterror",
379
- "apiconnectionerror",
380
- "serviceunavailableerror",
381
- "internalservererror",
382
- "timeout",
383
- ].includes(errName);
384
- }
385
-
386
- return false;
387
- }
388
-
389
358
  /**
390
359
  * Estimate cost for LLM API call.
391
360
  */
@@ -704,7 +673,7 @@ export class LLMClient {
704
673
  messages: Record<string, unknown>[],
705
674
  agentName?: string,
706
675
  tools?: string[],
707
- stream: boolean = false,
676
+ _stream: boolean = false,
708
677
  overrides?: Record<string, unknown>
709
678
  ): Promise<LLMResponse> {
710
679
  const temperature = (overrides?.temperature as number) ?? 0.7;
@@ -867,7 +836,9 @@ export class LLMClient {
867
836
  private async *callOpenAIStream(
868
837
  m: string, messages: Record<string, unknown>[], tools?: string[], temp?: number, maxTok?: number, signal?: AbortSignal, agentName?: string
869
838
  ): AsyncGenerator<StreamEvent> {
870
- const apiKey = this.getApiKey(m);
839
+ // Honor a per-agent API key override (agents.<name>.api_key) on the
840
+ // streaming path too — previously only the non-streaming path passed it.
841
+ const apiKey = this.getApiKey(m, agentName);
871
842
  const baseUrl = this.getBaseUrl(m);
872
843
  const body: Record<string, unknown> = {
873
844
  model: m, messages, temperature: temp ?? 0.7, max_tokens: maxTok ?? 4096,
@@ -6,15 +6,12 @@
6
6
  * limits, and dangerous command detection BEFORE execution.
7
7
  */
8
8
 
9
- import { execSync, exec } from "child_process";
9
+ import { execSync } from "child_process";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
12
  import * as os from "os";
13
- import { getLogger } from "./logger";
14
13
  import { REDLINE_PATTERNS, REDLINE_COMMANDS } from "./security";
15
14
 
16
- const log = getLogger("sandbox");
17
-
18
15
  /* ═══════════════════════════════════════
19
16
  Configuration
20
17
  ═══════════════════════════════════════ */
@@ -78,30 +78,6 @@ export interface MessageSchema {
78
78
  tool_call_id?: string;
79
79
  }
80
80
 
81
- /**
82
- * Coerce a value to a target type with best-effort conversion
83
- */
84
- function coerceType(value: unknown, targetType: string): unknown {
85
- if (value === null || value === undefined) {
86
- return value;
87
- }
88
-
89
- switch (targetType) {
90
- case "string":
91
- return String(value);
92
- case "number":
93
- return Number(value);
94
- case "boolean":
95
- return Boolean(value);
96
- case "array":
97
- return Array.isArray(value) ? value : [];
98
- case "object":
99
- return typeof value === "object" ? value : {};
100
- default:
101
- return value;
102
- }
103
- }
104
-
105
81
  /**
106
82
  * Extract JSON object/array from a potentially malformed string
107
83
  */
@@ -170,7 +146,7 @@ function repairJSON(text: string): string {
170
146
  */
171
147
  export function parseSchema<T extends Record<string, unknown>>(
172
148
  raw: string,
173
- schemaType?: new () => T
149
+ _schemaType?: new () => T
174
150
  ): T {
175
151
  if (!raw || !raw.trim()) {
176
152
  throw new SchemaValidationError("empty response", raw);
package/src/core/skill.ts CHANGED
@@ -245,7 +245,6 @@ function extractSkillHead(body: string, maxChars: number): string {
245
245
  // Patterns for auto-deriving triggers
246
246
  const TRIGGER_QUOTED = /["'"'""]([^"'""\n]{1,40})["'""']/g;
247
247
  const TRIGGER_EXT = /(?<![A-Za-z0-9])\.[A-Za-z0-9]{2,6}\b/g;
248
- const TRIGGER_STRIP = " \t,.;:!?,。、;:!?、。";
249
248
 
250
249
  /**
251
250
  * Pull candidate trigger phrases out of a skill description.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Terminal QR rendering — a thin, dependency-isolated wrapper over
3
+ * qrcode-terminal (single file, zero transitive deps). Returns the QR as a
4
+ * string so callers control where it's written (and so it's testable).
5
+ */
6
+
7
+ // qrcode-terminal ships no types; declare the tiny surface we use.
8
+ const qrcode: { generate: (text: string, opts: { small?: boolean }, cb: (s: string) => void) => void } =
9
+ require('qrcode-terminal');
10
+
11
+ /** Render `text` as a scannable QR code (compact) into a string. */
12
+ export function renderQR(text: string): string {
13
+ let out = '';
14
+ try {
15
+ qrcode.generate(text, { small: true }, (s: string) => { out = s; });
16
+ } catch {
17
+ return '';
18
+ }
19
+ return out;
20
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Channel setup metadata + persistence — the data behind the `sky channels`
3
+ * wizard. Each channel declares the credential fields it needs, where to create
4
+ * the bot (a platform console URL we render as a QR for quick mobile access),
5
+ * and a short how-to. Kept pure/testable; the interactive prompts live in the
6
+ * CLI, and QR rendering is a thin wrapper over qrcode-terminal.
7
+ *
8
+ * Note: Feishu / WeCom / QQ are all official-bot APIs — credentials are created
9
+ * in each platform's developer console, there is no "scan to log in" the way
10
+ * personal WeChat works. So the QR here is a convenience link to the console
11
+ * (scan on your phone → open the console → create the bot → copy the keys), plus
12
+ * a QR of the gateway callback URL to paste back into the console.
13
+ */
14
+
15
+ export interface ChannelField {
16
+ /** Config key under channels.<id>. */
17
+ key: string;
18
+ /** Human label shown in the wizard. */
19
+ label: string;
20
+ /** Whether the wizard must collect it (some are optional). */
21
+ required: boolean;
22
+ /** Treat as a secret (mask input / store as env-ref suggestion). */
23
+ secret?: boolean;
24
+ /** Env var that also supplies this value. */
25
+ env?: string;
26
+ /** One-line hint on where to find it. */
27
+ hint?: string;
28
+ }
29
+
30
+ export interface ChannelSetupSpec {
31
+ id: string;
32
+ name: string;
33
+ /** Platform console where the bot/app is created (rendered as a QR). */
34
+ consoleUrl: string;
35
+ /** Docs link for the full setup walkthrough. */
36
+ docsUrl?: string;
37
+ /** Webhook path the platform must call back. */
38
+ webhookPath: string;
39
+ /** Ordered credential fields to collect. */
40
+ fields: ChannelField[];
41
+ /** Short, numbered how-to shown before collecting fields. */
42
+ steps: string[];
43
+ }
44
+
45
+ export const CHANNEL_SETUP: Record<string, ChannelSetupSpec> = {
46
+ feishu: {
47
+ id: 'feishu',
48
+ name: '飞书 / Lark',
49
+ consoleUrl: 'https://open.feishu.cn/app',
50
+ docsUrl: 'https://open.feishu.cn/document/home/index',
51
+ webhookPath: '/webhook/feishu',
52
+ fields: [
53
+ { key: 'appId', label: 'App ID', required: true, env: 'FEISHU_APP_ID', hint: '开发者后台 → 凭证与基础信息 → App ID' },
54
+ { key: 'appSecret', label: 'App Secret', required: true, secret: true, env: 'FEISHU_APP_SECRET', hint: '同页 App Secret' },
55
+ { key: 'verificationToken', label: 'Verification Token', required: false, secret: true, env: 'FEISHU_VERIFICATION_TOKEN', hint: '事件订阅 → Verification Token(可选)' },
56
+ { key: 'encryptKey', label: 'Encrypt Key', required: false, secret: true, env: 'FEISHU_ENCRYPT_KEY', hint: '事件订阅 → Encrypt Key(开启加密时填)' },
57
+ ],
58
+ steps: [
59
+ '扫码或打开 https://open.feishu.cn/app 创建「企业自建应用」',
60
+ '在「凭证与基础信息」复制 App ID / App Secret',
61
+ '开启「机器人」能力,在「权限管理」添加 im:message 等权限',
62
+ '「事件订阅」填入下方回调 URL,订阅 im.message.receive_v1',
63
+ ],
64
+ },
65
+ wecom: {
66
+ id: 'wecom',
67
+ name: '企业微信 WeCom',
68
+ consoleUrl: 'https://work.weixin.qq.com/wework_admin/frame',
69
+ docsUrl: 'https://developer.work.weixin.qq.com/document/path/90664',
70
+ webhookPath: '/webhook/wecom',
71
+ fields: [
72
+ { key: 'corpId', label: 'CorpID(企业ID)', required: true, env: 'WECOM_CORP_ID', hint: '管理后台 → 我的企业 → 企业ID' },
73
+ { key: 'corpSecret', label: 'Secret(应用Secret)', required: true, secret: true, env: 'WECOM_CORP_SECRET', hint: '应用管理 → 自建应用 → Secret' },
74
+ { key: 'agentId', label: 'AgentId', required: true, env: 'WECOM_AGENT_ID', hint: '同应用页 AgentId' },
75
+ { key: 'token', label: 'Token', required: true, secret: true, env: 'WECOM_TOKEN', hint: '应用 → 接收消息 → API 接收 → Token' },
76
+ { key: 'encodingAesKey', label: 'EncodingAESKey', required: true, secret: true, env: 'WECOM_AES_KEY', hint: '同页 EncodingAESKey(43 位)' },
77
+ ],
78
+ steps: [
79
+ '扫码或打开企业微信管理后台,进入「应用管理 → 自建 → 创建应用」',
80
+ '复制企业ID、应用 Secret、AgentId',
81
+ '「接收消息」选 API 接收,设置 Token 与 EncodingAESKey',
82
+ '把下方回调 URL 填入「URL」,保存时企业微信会回调验证',
83
+ ],
84
+ },
85
+ qq: {
86
+ id: 'qq',
87
+ name: 'QQ 机器人',
88
+ consoleUrl: 'https://q.qq.com/#/app/bot',
89
+ docsUrl: 'https://bot.q.qq.com/wiki/',
90
+ webhookPath: '/webhook/qq',
91
+ fields: [
92
+ { key: 'appId', label: 'AppID(机器人ID)', required: true, env: 'QQ_BOT_APPID', hint: 'QQ 开放平台 → 机器人 → 开发设置 → AppID' },
93
+ { key: 'secret', label: 'AppSecret', required: true, secret: true, env: 'QQ_BOT_SECRET', hint: '同页 AppSecret' },
94
+ ],
95
+ steps: [
96
+ '扫码或打开 https://q.qq.com 创建机器人,完成开发者认证',
97
+ '在「开发设置」复制 AppID 与 AppSecret',
98
+ '「回调配置」选择 Webhook,填入下方回调 URL',
99
+ '在沙箱里把机器人加为好友 / 拉进群进行测试',
100
+ ],
101
+ },
102
+ };
103
+
104
+ export const SETUP_CHANNEL_IDS = Object.keys(CHANNEL_SETUP);
105
+
106
+ /** Build the full webhook callback URL for a channel from a public base. */
107
+ export function callbackUrl(base: string, channelId: string): string {
108
+ const spec = CHANNEL_SETUP[channelId];
109
+ if (!spec) return '';
110
+ const trimmed = base.replace(/\/+$/, '');
111
+ return `${trimmed}${spec.webhookPath}`;
112
+ }
113
+
114
+ /**
115
+ * Persist a channel's collected values into ~/.skyloom/config.yaml under
116
+ * channels.<id>, merging with any existing block. Secret-looking values are
117
+ * stored as-is (the file is chmod 0600); callers may instead keep secrets in
118
+ * env and store an { source: env, id } ref. Returns the config path written.
119
+ */
120
+ export function saveChannelConfig(
121
+ channelId: string,
122
+ values: Record<string, string>,
123
+ opts?: { configPath?: string },
124
+ ): string {
125
+ const path = require('path');
126
+ const fs = require('fs');
127
+ const yaml = require('yaml');
128
+ const cfgPath = opts?.configPath || path.join(require('os').homedir(), '.skyloom', 'config.yaml');
129
+ const dir = path.dirname(cfgPath);
130
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ let cfg: any = {};
132
+ if (fs.existsSync(cfgPath)) { try { cfg = yaml.parse(fs.readFileSync(cfgPath, 'utf-8')) || {}; } catch { cfg = {}; } }
133
+ if (!cfg.channels) cfg.channels = {};
134
+ cfg.channels[channelId] = { ...(cfg.channels[channelId] || {}), ...values, enabled: true };
135
+ fs.writeFileSync(cfgPath, yaml.stringify(cfg), { encoding: 'utf-8', mode: 0o600 });
136
+ try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on Windows */ }
137
+ return cfgPath;
138
+ }
139
+
140
+ /** Which required fields are still missing from a values map. */
141
+ export function missingRequired(channelId: string, values: Record<string, string>): string[] {
142
+ const spec = CHANNEL_SETUP[channelId];
143
+ if (!spec) return [];
144
+ return spec.fields.filter((f) => f.required && !values[f.key]?.trim()).map((f) => f.key);
145
+ }
@@ -5,11 +5,11 @@
5
5
  * processes and services, and install/uninstall software.
6
6
  */
7
7
 
8
- import { execSync, execFileSync, spawn } from 'child_process';
8
+ import { execSync, execFileSync } from 'child_process';
9
9
  import * as os from 'os';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
- import type { ToolRegistry, ToolDefinition } from '../core/tool';
12
+ import type { ToolRegistry } from '../core/tool';
13
13
 
14
14
  const MAX_OUT = 8000;
15
15
 
package/src/web/server.ts CHANGED
@@ -8,11 +8,8 @@
8
8
 
9
9
  import { createServer, IncomingMessage, ServerResponse } from "http";
10
10
  import { createSystemContext } from "../core/factory";
11
- import { getLogger } from "../core/logger";
12
11
  import { renderInkWashUI, SKYLOOM_FAVICON_SVG } from "./ui";
13
12
 
14
- const log = getLogger("web-server");
15
-
16
13
  /* ──────────────────────────────────────────────
17
14
  Server
18
15
  ────────────────────────────────────────────── */
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import {
6
+ CHANNEL_SETUP,
7
+ SETUP_CHANNEL_IDS,
8
+ callbackUrl,
9
+ saveChannelConfig,
10
+ missingRequired,
11
+ } from "../src/gateway/setup";
12
+ import { renderQR } from "../src/gateway/qr";
13
+
14
+ describe("channel setup · metadata", () => {
15
+ it("covers the three channels with console URLs and webhook paths", () => {
16
+ expect(SETUP_CHANNEL_IDS.sort()).toEqual(["feishu", "qq", "wecom"]);
17
+ for (const id of SETUP_CHANNEL_IDS) {
18
+ const s = CHANNEL_SETUP[id];
19
+ expect(s.consoleUrl).toMatch(/^https:\/\//);
20
+ expect(s.webhookPath).toBe(`/webhook/${id}`);
21
+ expect(s.fields.length).toBeGreaterThan(0);
22
+ expect(s.steps.length).toBeGreaterThan(0);
23
+ }
24
+ });
25
+
26
+ it("each channel marks its credential fields with env fallbacks", () => {
27
+ const feishu = CHANNEL_SETUP.feishu;
28
+ const appId = feishu.fields.find((f) => f.key === "appId")!;
29
+ expect(appId.required).toBe(true);
30
+ expect(appId.env).toBe("FEISHU_APP_ID");
31
+ const secret = feishu.fields.find((f) => f.key === "appSecret")!;
32
+ expect(secret.secret).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe("channel setup · callbackUrl", () => {
37
+ it("joins base + webhook path, trimming trailing slash", () => {
38
+ expect(callbackUrl("https://bot.example.com", "feishu")).toBe("https://bot.example.com/webhook/feishu");
39
+ expect(callbackUrl("https://bot.example.com/", "wecom")).toBe("https://bot.example.com/webhook/wecom");
40
+ expect(callbackUrl("http://localhost:8848", "qq")).toBe("http://localhost:8848/webhook/qq");
41
+ });
42
+ });
43
+
44
+ describe("channel setup · missingRequired", () => {
45
+ it("reports unfilled required fields, ignores optional", () => {
46
+ expect(missingRequired("feishu", { appId: "a" })).toEqual(["appSecret"]);
47
+ expect(missingRequired("feishu", { appId: "a", appSecret: "s" })).toEqual([]);
48
+ // optional fields (verificationToken/encryptKey) never reported
49
+ expect(missingRequired("feishu", { appId: "a", appSecret: "s" })).not.toContain("encryptKey");
50
+ });
51
+ });
52
+
53
+ describe("channel setup · saveChannelConfig", () => {
54
+ let cfgPath: string;
55
+ beforeEach(() => { cfgPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "sky-chcfg-")), "config.yaml"); });
56
+ afterEach(() => { try { fs.rmSync(path.dirname(cfgPath), { recursive: true, force: true }); } catch {} });
57
+
58
+ it("writes channels.<id> and merges on re-save", () => {
59
+ saveChannelConfig("feishu", { appId: "a", appSecret: "s" }, { configPath: cfgPath });
60
+ const yaml = require("yaml");
61
+ let cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
62
+ expect(cfg.channels.feishu).toMatchObject({ appId: "a", appSecret: "s", enabled: true });
63
+
64
+ // re-save adds a field without dropping the old ones
65
+ saveChannelConfig("feishu", { encryptKey: "k" }, { configPath: cfgPath });
66
+ cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
67
+ expect(cfg.channels.feishu).toMatchObject({ appId: "a", appSecret: "s", encryptKey: "k" });
68
+ });
69
+
70
+ it("preserves other top-level config when writing a channel", () => {
71
+ const yaml = require("yaml");
72
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
73
+ fs.writeFileSync(cfgPath, yaml.stringify({ default_model: "gpt-4o", api_keys: { openai: "sk" } }));
74
+ saveChannelConfig("qq", { appId: "1", secret: "x" }, { configPath: cfgPath });
75
+ const cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
76
+ expect(cfg.default_model).toBe("gpt-4o");
77
+ expect(cfg.api_keys.openai).toBe("sk");
78
+ expect(cfg.channels.qq.appId).toBe("1");
79
+ });
80
+ });
81
+
82
+ describe("channel setup · QR rendering", () => {
83
+ it("renders a non-empty scannable block for a URL", () => {
84
+ const qr = renderQR("https://open.feishu.cn/app");
85
+ expect(qr.length).toBeGreaterThan(50);
86
+ expect(qr).toMatch(/[█▀▄ ]/); // block-drawing characters
87
+ });
88
+ });