@rynx-ai/daemon 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/dist/agent-file.d.ts +13 -0
- package/dist/agent-file.js +61 -0
- package/dist/channel-store.d.ts +28 -0
- package/dist/channel-store.js +81 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -0
- package/dist/control-deps.d.ts +2 -0
- package/dist/control-deps.js +115 -0
- package/dist/daemon-server.d.ts +7 -0
- package/dist/daemon-server.js +90 -0
- package/dist/db.d.ts +5 -0
- package/dist/db.js +170 -0
- package/dist/index-daemon.d.ts +2 -0
- package/dist/index-daemon.js +20 -0
- package/dist/instance-store.d.ts +38 -0
- package/dist/instance-store.js +85 -0
- package/dist/migrations/cleanup-legacy-sessions.d.ts +18 -0
- package/dist/migrations/cleanup-legacy-sessions.js +123 -0
- package/dist/plugin-installer.d.ts +27 -0
- package/dist/plugin-installer.js +186 -0
- package/dist/plugin-store.d.ts +27 -0
- package/dist/plugin-store.js +56 -0
- package/dist/pm2.d.ts +19 -0
- package/dist/pm2.js +153 -0
- package/dist/registry.d.ts +23 -0
- package/dist/registry.js +117 -0
- package/dist/session-log-store.d.ts +22 -0
- package/dist/session-log-store.js +91 -0
- package/dist/session-meta-store.d.ts +24 -0
- package/dist/session-meta-store.js +59 -0
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +235 -0
- package/dist/skills-catalog.d.ts +18 -0
- package/dist/skills-catalog.js +94 -0
- package/dist/stdio-epipe-guard.d.ts +8 -0
- package/dist/stdio-epipe-guard.js +16 -0
- package/dist/update.d.ts +16 -0
- package/dist/update.js +160 -0
- package/package.json +37 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
function toItem(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.id,
|
|
5
|
+
sessionId: row.session_id,
|
|
6
|
+
position: row.position,
|
|
7
|
+
responseId: row.response_id,
|
|
8
|
+
status: row.status,
|
|
9
|
+
createdAt: row.created_at,
|
|
10
|
+
...(row.created_by ? { createdBy: row.created_by } : {}),
|
|
11
|
+
type: row.type,
|
|
12
|
+
data: JSON.parse(row.data),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export class SqliteSessionLogStore {
|
|
16
|
+
async append(sessionId, items) {
|
|
17
|
+
if (items.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const conn = db();
|
|
20
|
+
const insert = conn.prepare(`INSERT INTO session_items
|
|
21
|
+
(id, session_id, position, response_id, type, status, data, created_by, created_at)
|
|
22
|
+
VALUES (@id, @session_id, @position, @response_id, @type, @status, @data, @created_by, @created_at)`);
|
|
23
|
+
const maxStmt = conn.prepare("SELECT MAX(position) AS max FROM session_items WHERE session_id = ?");
|
|
24
|
+
const tx = conn.transaction((batch) => {
|
|
25
|
+
const { max } = maxStmt.get(sessionId);
|
|
26
|
+
let next = (max ?? -1) + 1;
|
|
27
|
+
return batch.map((item) => {
|
|
28
|
+
const stored = { ...item, sessionId, position: next++ };
|
|
29
|
+
insert.run({
|
|
30
|
+
id: stored.id,
|
|
31
|
+
session_id: sessionId,
|
|
32
|
+
position: stored.position,
|
|
33
|
+
response_id: stored.responseId,
|
|
34
|
+
type: stored.type,
|
|
35
|
+
status: stored.status,
|
|
36
|
+
data: JSON.stringify(stored.data),
|
|
37
|
+
created_by: stored.createdBy ?? null,
|
|
38
|
+
created_at: stored.createdAt,
|
|
39
|
+
});
|
|
40
|
+
return stored;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return tx(items);
|
|
44
|
+
}
|
|
45
|
+
async list(sessionId, opts = {}) {
|
|
46
|
+
const conn = db();
|
|
47
|
+
let afterPosition = -1;
|
|
48
|
+
if (opts.afterId) {
|
|
49
|
+
const cursor = conn
|
|
50
|
+
.prepare("SELECT position FROM session_items WHERE id = ?")
|
|
51
|
+
.get(opts.afterId);
|
|
52
|
+
// Unknown cursor ⇒ empty page (rather than silently returning everything).
|
|
53
|
+
if (!cursor)
|
|
54
|
+
return [];
|
|
55
|
+
afterPosition = cursor.position;
|
|
56
|
+
}
|
|
57
|
+
const limit = opts.limit ?? -1; // SQLite treats LIMIT -1 as "no limit"
|
|
58
|
+
const rows = conn
|
|
59
|
+
.prepare(`SELECT * FROM session_items
|
|
60
|
+
WHERE session_id = ? AND position > ?
|
|
61
|
+
ORDER BY position ASC
|
|
62
|
+
LIMIT ?`)
|
|
63
|
+
.all(sessionId, afterPosition, limit);
|
|
64
|
+
return rows.map(toItem);
|
|
65
|
+
}
|
|
66
|
+
async snapshot(sessionId) {
|
|
67
|
+
const rows = db()
|
|
68
|
+
.prepare("SELECT * FROM session_items WHERE session_id = ? ORDER BY position ASC")
|
|
69
|
+
.all(sessionId);
|
|
70
|
+
return rows.map(toItem);
|
|
71
|
+
}
|
|
72
|
+
async listSessions(opts = {}) {
|
|
73
|
+
const limit = opts.limit ?? 200;
|
|
74
|
+
const rows = db()
|
|
75
|
+
.prepare(`SELECT session_id, MIN(created_at) AS first_at, MAX(created_at) AS last_at, COUNT(*) AS cnt
|
|
76
|
+
FROM session_items
|
|
77
|
+
GROUP BY session_id
|
|
78
|
+
ORDER BY last_at DESC
|
|
79
|
+
LIMIT ?`)
|
|
80
|
+
.all(limit);
|
|
81
|
+
return rows.map((r) => ({
|
|
82
|
+
sessionId: r.session_id,
|
|
83
|
+
createdAt: r.first_at,
|
|
84
|
+
updatedAt: r.last_at,
|
|
85
|
+
itemCount: r.cnt,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
async deleteSession(sessionId) {
|
|
89
|
+
db().prepare("DELETE FROM session_items WHERE session_id = ?").run(sessionId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified machine-session record (SQLite) — one row per session regardless
|
|
3
|
+
* of origin (the web console AND every channel). Holds identity/meta (source /
|
|
4
|
+
* agent / inline config / model / title / …) so the **session list survives a
|
|
5
|
+
* daemon restart**; the transcript lives in `session_items` (see
|
|
6
|
+
* {@link import("./session-log-store.js")}). Runtime `status` is intentionally
|
|
7
|
+
* not stored — it is reconstructed as `idle` after a restart.
|
|
8
|
+
*
|
|
9
|
+
* Exposes both the standalone functions the control-plane deps wire in and a
|
|
10
|
+
* {@link SessionRegistry} adapter ({@link sessionRegistry}) injected into channels
|
|
11
|
+
* via the `ChannelContext`, so a channel registers its sessions the same way the
|
|
12
|
+
* console does.
|
|
13
|
+
*/
|
|
14
|
+
import type { MachineSessionRecord, SessionRegistry } from "@rynx-ai/core";
|
|
15
|
+
/** Back-compat alias for the unified record. */
|
|
16
|
+
export type StoredSessionMeta = MachineSessionRecord;
|
|
17
|
+
export declare function listSessionMetas(): StoredSessionMeta[];
|
|
18
|
+
export declare function getSessionMeta(id: string): StoredSessionMeta | undefined;
|
|
19
|
+
export declare function createSessionMeta(meta: StoredSessionMeta): void;
|
|
20
|
+
export declare function setSessionTitle(id: string, title: string): void;
|
|
21
|
+
export declare function removeSessionMeta(id: string): void;
|
|
22
|
+
/** {@link SessionRegistry} over the same `sessions` table — the form channels
|
|
23
|
+
* receive through their `ChannelContext`. */
|
|
24
|
+
export declare const sessionRegistry: SessionRegistry;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
const COLS = "id, agent, config, model, reasoning_effort, title, source, created_at, updated_at";
|
|
3
|
+
export function listSessionMetas() {
|
|
4
|
+
return db()
|
|
5
|
+
.prepare(`SELECT ${COLS} FROM sessions ORDER BY created_at`)
|
|
6
|
+
.all()
|
|
7
|
+
.map((row) => rowToMeta(row));
|
|
8
|
+
}
|
|
9
|
+
export function getSessionMeta(id) {
|
|
10
|
+
const row = db().prepare(`SELECT ${COLS} FROM sessions WHERE id = ?`).get(id);
|
|
11
|
+
return row ? rowToMeta(row) : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function createSessionMeta(meta) {
|
|
14
|
+
db()
|
|
15
|
+
.prepare(`INSERT OR IGNORE INTO sessions
|
|
16
|
+
(id, agent, config, model, reasoning_effort, title, source, created_at, updated_at)
|
|
17
|
+
VALUES (@id, @agent, @config, @model, @reasoning_effort, @title, @source, @created_at, @updated_at)`)
|
|
18
|
+
.run({
|
|
19
|
+
id: meta.id,
|
|
20
|
+
agent: meta.agent ?? null,
|
|
21
|
+
config: meta.config ? JSON.stringify(meta.config) : null,
|
|
22
|
+
model: meta.model ?? null,
|
|
23
|
+
reasoning_effort: meta.reasoningEffort ?? null,
|
|
24
|
+
title: meta.title ?? null,
|
|
25
|
+
source: meta.source,
|
|
26
|
+
created_at: meta.createdAt,
|
|
27
|
+
updated_at: meta.updatedAt ?? meta.createdAt,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function setSessionTitle(id, title) {
|
|
31
|
+
db()
|
|
32
|
+
.prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?")
|
|
33
|
+
.run(title, new Date().toISOString(), id);
|
|
34
|
+
}
|
|
35
|
+
export function removeSessionMeta(id) {
|
|
36
|
+
db().prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
37
|
+
}
|
|
38
|
+
/** {@link SessionRegistry} over the same `sessions` table — the form channels
|
|
39
|
+
* receive through their `ChannelContext`. */
|
|
40
|
+
export const sessionRegistry = {
|
|
41
|
+
create: createSessionMeta,
|
|
42
|
+
get: getSessionMeta,
|
|
43
|
+
list: listSessionMetas,
|
|
44
|
+
setTitle: setSessionTitle,
|
|
45
|
+
remove: removeSessionMeta,
|
|
46
|
+
};
|
|
47
|
+
function rowToMeta(row) {
|
|
48
|
+
return {
|
|
49
|
+
id: row.id,
|
|
50
|
+
agent: row.agent ?? undefined,
|
|
51
|
+
config: row.config ? JSON.parse(row.config) : undefined,
|
|
52
|
+
model: row.model ?? undefined,
|
|
53
|
+
reasoningEffort: (row.reasoning_effort ?? undefined),
|
|
54
|
+
title: row.title ?? undefined,
|
|
55
|
+
source: row.source ?? "console",
|
|
56
|
+
createdAt: row.created_at,
|
|
57
|
+
updatedAt: row.updated_at ?? row.created_at,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Walk the user through config.json step by step. Returns a process exit code. */
|
|
2
|
+
export declare function runSetup(): Promise<number>;
|
|
3
|
+
/** Read-only diagnostics. Returns a process exit code (0 ok, 1 on any failure). */
|
|
4
|
+
export declare function runDoctor(): Promise<number>;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rynx setup` / `rynx doctor` — bootstrap + health check for the daemon home
|
|
3
|
+
* (`~/.rynx`). Replaces the old repo-bound `scripts/setup.sh`: config lives in
|
|
4
|
+
* `{RYNX_HOME}/config.json` and the CLI ships as a global binary, so this belongs
|
|
5
|
+
* here where it can also read the SQLite store directly. The interactive UI is
|
|
6
|
+
* rendered with `@clack/prompts`.
|
|
7
|
+
*
|
|
8
|
+
* rynx setup interactive, step-by-step wizard that writes ~/.rynx/config.json
|
|
9
|
+
* (falls back to writing defaults when stdin isn't a TTY)
|
|
10
|
+
* rynx doctor read-only diagnostics (creates nothing; non-zero exit on failure)
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import * as p from "@clack/prompts";
|
|
16
|
+
import { AGENT_RUNTIME_IDS, getRuntimeProfile, listAgentSpecs, loadConfig, resolveRuntimeHome, rynxAgentsDir, rynxConfigFile, rynxHome, } from "@rynx-ai/core";
|
|
17
|
+
import { listChannels } from "./channel-store.js";
|
|
18
|
+
import { dbPath } from "./db.js";
|
|
19
|
+
import { listInstances } from "./instance-store.js";
|
|
20
|
+
import { listPlugins } from "./plugin-store.js";
|
|
21
|
+
import { formatControlUrl } from "./pm2.js";
|
|
22
|
+
const MIN_NODE_MAJOR = 20;
|
|
23
|
+
// ── rynx setup — interactive wizard ─────────────────────────────────────────
|
|
24
|
+
/** Walk the user through config.json step by step. Returns a process exit code. */
|
|
25
|
+
export async function runSetup() {
|
|
26
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
27
|
+
// Non-interactive (CI / piped): never block on a prompt — just ensure defaults.
|
|
28
|
+
if (!existsSync(rynxConfigFile()))
|
|
29
|
+
writeConfig({ HOST: "0.0.0.0", PORT: 3000, LOG_LEVEL: "info" });
|
|
30
|
+
console.log(`非交互环境:已确保 ${rynxConfigFile()} 存在(默认值)。` +
|
|
31
|
+
"在终端(TTY)下重跑 `rynx setup` 可分步配置,或直接编辑该文件。");
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
p.intro("rynx setup · 配置 ~/.rynx");
|
|
35
|
+
const cfg = readConfig();
|
|
36
|
+
// Runtimes: show status, offer login for any logged-out codex/traex, pick default.
|
|
37
|
+
let states = AGENT_RUNTIME_IDS.map(runtimeState);
|
|
38
|
+
p.note(states.map((s) => `${statusGlyph(s)} ${s.id.padEnd(7)} ${runtimeHint(s)}`).join("\n"), "运行时");
|
|
39
|
+
for (const s of states) {
|
|
40
|
+
if (s.installed && s.login === "logged-out" && s.id !== "claude") {
|
|
41
|
+
const yes = await p.confirm({ message: `现在登录 ${s.id}(${s.profile.loginCommand})?`, initialValue: false });
|
|
42
|
+
if (p.isCancel(yes))
|
|
43
|
+
return bail();
|
|
44
|
+
if (yes)
|
|
45
|
+
spawnSync(s.bin, ["login"], { stdio: "inherit" });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const claude = states.find((s) => s.id === "claude");
|
|
49
|
+
if (claude?.installed && claude.login === "logged-out") {
|
|
50
|
+
p.log.info("claude 登录:运行 `claude /login`,或设置 ANTHROPIC_API_KEY 后重来");
|
|
51
|
+
}
|
|
52
|
+
states = AGENT_RUNTIME_IDS.map(runtimeState); // re-probe after any login
|
|
53
|
+
const installed = states.filter((s) => s.installed);
|
|
54
|
+
if (installed.length === 0) {
|
|
55
|
+
p.log.warn("没有检测到运行时;装好 codex / traex / claude 之一后可设默认。");
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const current = typeof cfg.AGENT_RUNTIME === "string" ? cfg.AGENT_RUNTIME : undefined;
|
|
59
|
+
const initial = current ?? installed.find((s) => s.login === "logged-in")?.id ?? installed[0].id;
|
|
60
|
+
const runtime = await p.select({
|
|
61
|
+
message: "默认运行时 AGENT_RUNTIME",
|
|
62
|
+
initialValue: initial,
|
|
63
|
+
options: installed.map((s) => ({ value: s.id, label: s.id, hint: runtimeHint(s) })),
|
|
64
|
+
});
|
|
65
|
+
if (p.isCancel(runtime))
|
|
66
|
+
return bail();
|
|
67
|
+
cfg.AGENT_RUNTIME = runtime;
|
|
68
|
+
}
|
|
69
|
+
// HTTP port.
|
|
70
|
+
const port = await p.text({
|
|
71
|
+
message: "HTTP 端口 PORT",
|
|
72
|
+
placeholder: "3000",
|
|
73
|
+
initialValue: String(cfg.PORT ?? 3000),
|
|
74
|
+
validate: (v) => {
|
|
75
|
+
const n = Number(v);
|
|
76
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535)
|
|
77
|
+
return "请输入 1–65535 之间的端口";
|
|
78
|
+
return undefined;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
if (p.isCancel(port))
|
|
82
|
+
return bail();
|
|
83
|
+
cfg.PORT = Number(port);
|
|
84
|
+
cfg.HOST = cfg.HOST ?? "0.0.0.0";
|
|
85
|
+
cfg.LOG_LEVEL = cfg.LOG_LEVEL ?? "info";
|
|
86
|
+
writeConfig(cfg);
|
|
87
|
+
p.log.success(`已写入 ${rynxConfigFile()}(chmod 600)`);
|
|
88
|
+
p.log.message(`HOST=${cfg.HOST} PORT=${cfg.PORT} AGENT_RUNTIME=${cfg.AGENT_RUNTIME ?? "(默认)"}`);
|
|
89
|
+
const url = formatControlUrl(String(cfg.HOST ?? "0.0.0.0"), Number(cfg.PORT ?? 3000));
|
|
90
|
+
p.note([
|
|
91
|
+
`启动常驻服务 rynx start`,
|
|
92
|
+
`打开控制台 ${url}`,
|
|
93
|
+
`新建 agent rynx agent add <id>`,
|
|
94
|
+
`随时体检 rynx doctor`,
|
|
95
|
+
].join("\n"), "下一步");
|
|
96
|
+
p.outro("配置完成 🎉");
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
// ── rynx doctor — read-only health check ────────────────────────────────────
|
|
100
|
+
/** Read-only diagnostics. Returns a process exit code (0 ok, 1 on any failure). */
|
|
101
|
+
export async function runDoctor() {
|
|
102
|
+
let warn = 0;
|
|
103
|
+
let fail = 0;
|
|
104
|
+
const home = rynxHome();
|
|
105
|
+
p.intro("rynx doctor · 只读体检");
|
|
106
|
+
p.note([
|
|
107
|
+
`RYNX_HOME ${home}`,
|
|
108
|
+
`config ${rynxConfigFile()}`,
|
|
109
|
+
`db ${dbPath()}`,
|
|
110
|
+
`agents ${rynxAgentsDir()}`,
|
|
111
|
+
`logs ${join(home, "logs")}`,
|
|
112
|
+
].join("\n"), "路径");
|
|
113
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
114
|
+
if (major >= MIN_NODE_MAJOR)
|
|
115
|
+
p.log.success(`Node.js v${process.versions.node}(要求 ≥ ${MIN_NODE_MAJOR})`);
|
|
116
|
+
else
|
|
117
|
+
(p.log.error(`Node.js v${process.versions.node} 过低,要求 ≥ ${MIN_NODE_MAJOR}`), fail++);
|
|
118
|
+
if (existsSync(rynxConfigFile()))
|
|
119
|
+
p.log.success(`config.json 已存在`);
|
|
120
|
+
else
|
|
121
|
+
(p.log.warn("缺少 config.json —— 运行 `rynx setup` 生成"), warn++);
|
|
122
|
+
try {
|
|
123
|
+
const config = loadConfig();
|
|
124
|
+
p.log.info(`HOST=${config.HOST} PORT=${config.PORT} LOG_LEVEL=${config.LOG_LEVEL} AGENT_RUNTIME=${config.AGENT_RUNTIME}`);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
p.log.error(`配置解析失败:${error.message}`);
|
|
128
|
+
fail++;
|
|
129
|
+
}
|
|
130
|
+
let anyReady = false;
|
|
131
|
+
for (const s of AGENT_RUNTIME_IDS.map(runtimeState)) {
|
|
132
|
+
if (!s.installed)
|
|
133
|
+
p.log.info(`${s.id}:未安装(${s.bin} 不在 PATH)`);
|
|
134
|
+
else if (s.login === "logged-in")
|
|
135
|
+
(p.log.success(`${s.id}:已登录`), (anyReady = true));
|
|
136
|
+
else if (s.login === "logged-out")
|
|
137
|
+
(p.log.warn(`${s.id}:未登录 —— 运行 \`${s.profile.loginCommand}\``), warn++);
|
|
138
|
+
else
|
|
139
|
+
(p.log.warn(`${s.id}:已安装,登录状态待确认(若报鉴权错误,执行 \`${s.profile.loginCommand}\`)`), (anyReady = true));
|
|
140
|
+
}
|
|
141
|
+
if (!anyReady)
|
|
142
|
+
(p.log.error("没有就绪的运行时 —— 至少登录一个:codex login / traex login / claude /login"), fail++);
|
|
143
|
+
if (!existsSync(dbPath())) {
|
|
144
|
+
p.log.info("rynx.db 尚未创建(首次 `rynx start` 时生成)");
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const plugins = listPlugins();
|
|
148
|
+
p.log.info(`插件:${plugins.length} 个${plugins.length ? `(${plugins.map((pl) => pl.name).join(", ")})` : ""}`);
|
|
149
|
+
const channels = listChannels();
|
|
150
|
+
const configured = channels.filter((c) => c.options && Object.keys(c.options).length > 0).length;
|
|
151
|
+
p.log.info(`channel:${channels.length} 个(${configured} 已配置凭据) · instance:${listInstances().length} 个`);
|
|
152
|
+
if (channels.length === 0)
|
|
153
|
+
(p.log.warn("还没有 channel —— 在 Web 控制台新建并授权"), warn++);
|
|
154
|
+
}
|
|
155
|
+
p.log.info(`agent:${(await listAgentSpecs()).length} 个(${rynxAgentsDir()})`);
|
|
156
|
+
if (fail > 0) {
|
|
157
|
+
p.outro(`体检发现 ${fail} 个失败项(警告 ${warn})—— 处理后重跑 rynx doctor`);
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
p.outro(warn > 0 ? `基本就绪,有 ${warn} 个警告` : "全部通过 ✓");
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
function runtimeState(id) {
|
|
164
|
+
const profile = getRuntimeProfile(id);
|
|
165
|
+
const installed = onPath(profile.defaultBinary);
|
|
166
|
+
const login = !installed
|
|
167
|
+
? "logged-out"
|
|
168
|
+
: id === "claude"
|
|
169
|
+
? claudeAuthState(profile)
|
|
170
|
+
: cliLoginState(profile.defaultBinary);
|
|
171
|
+
return { id, bin: profile.defaultBinary, profile, installed, login };
|
|
172
|
+
}
|
|
173
|
+
function statusGlyph(s) {
|
|
174
|
+
if (!s.installed)
|
|
175
|
+
return "○";
|
|
176
|
+
return s.login === "logged-in" ? "●" : "◍";
|
|
177
|
+
}
|
|
178
|
+
function runtimeHint(s) {
|
|
179
|
+
if (!s.installed)
|
|
180
|
+
return `未安装(${s.bin} 不在 PATH)`;
|
|
181
|
+
if (s.login === "logged-in")
|
|
182
|
+
return "已登录";
|
|
183
|
+
if (s.login === "logged-out")
|
|
184
|
+
return "已安装,未登录";
|
|
185
|
+
return "已安装,状态待确认";
|
|
186
|
+
}
|
|
187
|
+
/** Is `bin` runnable on PATH? Probe with `--version` (ENOENT ⇒ not installed). */
|
|
188
|
+
function onPath(bin) {
|
|
189
|
+
const result = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 8000 });
|
|
190
|
+
return !result.error && result.status === 0;
|
|
191
|
+
}
|
|
192
|
+
/** Parse `<bin> login status` (codex/traex) into a tri-state. */
|
|
193
|
+
function cliLoginState(bin) {
|
|
194
|
+
const result = spawnSync(bin, ["login", "status"], { encoding: "utf8", timeout: 12000 });
|
|
195
|
+
const out = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
196
|
+
if (/Logged in using/i.test(out))
|
|
197
|
+
return "logged-in";
|
|
198
|
+
if (/not logged in|logged out/i.test(out))
|
|
199
|
+
return "logged-out";
|
|
200
|
+
return "unknown";
|
|
201
|
+
}
|
|
202
|
+
/** Claude has no `login status`; auth is an API-key env var or the `~/.claude` config dir. */
|
|
203
|
+
function claudeAuthState(profile) {
|
|
204
|
+
if (process.env.ANTHROPIC_API_KEY?.trim() || process.env.ANTHROPIC_AUTH_TOKEN?.trim())
|
|
205
|
+
return "logged-in";
|
|
206
|
+
return existsSync(resolveRuntimeHome(profile)) ? "unknown" : "logged-out";
|
|
207
|
+
}
|
|
208
|
+
function readConfig() {
|
|
209
|
+
const file = rynxConfigFile();
|
|
210
|
+
if (!existsSync(file))
|
|
211
|
+
return {};
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
214
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return {};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function writeConfig(cfg) {
|
|
221
|
+
const file = rynxConfigFile();
|
|
222
|
+
mkdirSync(rynxHome(), { recursive: true });
|
|
223
|
+
writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
224
|
+
try {
|
|
225
|
+
chmodSync(file, 0o600);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* best-effort on platforms without POSIX modes */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** User pressed Ctrl-C mid-wizard: print a cancel note and exit cleanly. */
|
|
232
|
+
function bail() {
|
|
233
|
+
p.cancel("已取消,未保存改动。");
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SkillInstallRecipe, type SkillMeta } from "@rynx-ai/core";
|
|
2
|
+
import type { SkillDetail } from "@rynx-ai/protocol/control";
|
|
3
|
+
export declare function listCatalogSkills(): Promise<SkillMeta[]>;
|
|
4
|
+
/** Install a skill (or a repo's skills) into the catalog per a full install
|
|
5
|
+
* recipe; delegates to the core catalog layer (which also writes each skill's
|
|
6
|
+
* `.rynx-skill.json` install-info file — a catalog-only artifact). Returns
|
|
7
|
+
* the refreshed catalog. */
|
|
8
|
+
export declare function installCatalogSkill(recipe: SkillInstallRecipe): Promise<SkillMeta[]>;
|
|
9
|
+
/** Detail for one catalog skill: its metadata + the file tree under its dir. */
|
|
10
|
+
export declare function getCatalogSkill(name: string): Promise<SkillDetail | null>;
|
|
11
|
+
/** Read one text file inside a catalog skill. Path-confined to the skill dir,
|
|
12
|
+
* size-capped, and binary files are rejected. */
|
|
13
|
+
export declare function readCatalogSkillFile(name: string, relPath: string): Promise<{
|
|
14
|
+
path: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}>;
|
|
17
|
+
/** Remove a catalog skill by name. Returns false if it wasn't present. */
|
|
18
|
+
export declare function removeCatalogSkill(name: string): Promise<boolean>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's global skill catalog at `~/.rynx/skills`. Install + provenance
|
|
3
|
+
* live in `@rynx-ai/core` (`skills-install.ts`) so the daemon API and the runtime
|
|
4
|
+
* env-build share one implementation; this module is the daemon-facing wrapper
|
|
5
|
+
* plus the read-only detail/file helpers the Skills page uses.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { extractSkillFrontmatter, installIntoCatalog, rynxSkillsDir, scanSkillsDir, readSkillMeta, } from "@rynx-ai/core";
|
|
10
|
+
/** Catalog skill ids are kebab-case (matches the SKILL.md `name` convention). */
|
|
11
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
12
|
+
/** A single file's contents are capped to keep responses small. */
|
|
13
|
+
const MAX_SKILL_FILE_BYTES = 256 * 1024;
|
|
14
|
+
export function listCatalogSkills() {
|
|
15
|
+
return scanSkillsDir(rynxSkillsDir());
|
|
16
|
+
}
|
|
17
|
+
/** Install a skill (or a repo's skills) into the catalog per a full install
|
|
18
|
+
* recipe; delegates to the core catalog layer (which also writes each skill's
|
|
19
|
+
* `.rynx-skill.json` install-info file — a catalog-only artifact). Returns
|
|
20
|
+
* the refreshed catalog. */
|
|
21
|
+
export async function installCatalogSkill(recipe) {
|
|
22
|
+
await installIntoCatalog(recipe, rynxSkillsDir());
|
|
23
|
+
return scanSkillsDir(rynxSkillsDir());
|
|
24
|
+
}
|
|
25
|
+
/** Detail for one catalog skill: its metadata + the file tree under its dir. */
|
|
26
|
+
export async function getCatalogSkill(name) {
|
|
27
|
+
if (!SKILL_NAME_PATTERN.test(name))
|
|
28
|
+
throw new Error(`invalid skill name: ${name}`);
|
|
29
|
+
const dir = path.join(rynxSkillsDir(), name);
|
|
30
|
+
const meta = await readSkillMeta(dir);
|
|
31
|
+
if (!meta)
|
|
32
|
+
return null;
|
|
33
|
+
const files = await walkSkillFiles(dir, dir);
|
|
34
|
+
let frontmatter;
|
|
35
|
+
try {
|
|
36
|
+
frontmatter = extractSkillFrontmatter(await readFile(path.join(dir, "SKILL.md"), "utf8")) ?? undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* SKILL.md unreadable — leave frontmatter undefined */
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
name: meta.name,
|
|
43
|
+
description: meta.description,
|
|
44
|
+
...(meta.ref ? { ref: meta.ref } : {}),
|
|
45
|
+
frontmatter,
|
|
46
|
+
totalSize: files.reduce((sum, f) => sum + f.size, 0),
|
|
47
|
+
files,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function walkSkillFiles(root, dir) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
53
|
+
const full = path.join(dir, entry.name);
|
|
54
|
+
if (entry.isDirectory())
|
|
55
|
+
out.push(...(await walkSkillFiles(root, full)));
|
|
56
|
+
else if (entry.isFile())
|
|
57
|
+
out.push({ path: path.relative(root, full), size: (await stat(full)).size });
|
|
58
|
+
}
|
|
59
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
60
|
+
}
|
|
61
|
+
/** Read one text file inside a catalog skill. Path-confined to the skill dir,
|
|
62
|
+
* size-capped, and binary files are rejected. */
|
|
63
|
+
export async function readCatalogSkillFile(name, relPath) {
|
|
64
|
+
if (!SKILL_NAME_PATTERN.test(name))
|
|
65
|
+
throw new Error(`invalid skill name: ${name}`);
|
|
66
|
+
const dir = path.resolve(rynxSkillsDir(), name);
|
|
67
|
+
const target = path.resolve(dir, relPath);
|
|
68
|
+
if (target !== dir && !target.startsWith(dir + path.sep)) {
|
|
69
|
+
throw new Error("path escapes the skill directory");
|
|
70
|
+
}
|
|
71
|
+
const s = await stat(target).catch(() => null);
|
|
72
|
+
if (!s || !s.isFile())
|
|
73
|
+
throw new Error("file not found");
|
|
74
|
+
if (s.size > MAX_SKILL_FILE_BYTES)
|
|
75
|
+
throw new Error(`file too large (${s.size} bytes)`);
|
|
76
|
+
const buf = await readFile(target);
|
|
77
|
+
if (buf.includes(0))
|
|
78
|
+
throw new Error("binary file — not viewable");
|
|
79
|
+
return { path: relPath, content: buf.toString("utf8") };
|
|
80
|
+
}
|
|
81
|
+
/** Remove a catalog skill by name. Returns false if it wasn't present. */
|
|
82
|
+
export async function removeCatalogSkill(name) {
|
|
83
|
+
if (!SKILL_NAME_PATTERN.test(name))
|
|
84
|
+
throw new Error(`invalid skill name: ${name}`);
|
|
85
|
+
const dir = path.join(rynxSkillsDir(), name);
|
|
86
|
+
try {
|
|
87
|
+
await stat(dir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
await rm(dir, { recursive: true, force: true });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Under pm2 the daemon's stdout/stderr are pipes to the pm2 God daemon. A
|
|
3
|
+
* broken pipe (a `pm2 logs` stream detaching, or a pm2 restart) would otherwise
|
|
4
|
+
* surface as an unhandled `'error'` on the stream and crash a daemon that has
|
|
5
|
+
* no uncaughtException trap. Swallow EPIPE on the std streams; re-throw anything
|
|
6
|
+
* else. (Borrowed from botmux's daemon bootstrap.)
|
|
7
|
+
*/
|
|
8
|
+
export declare function installStdioEpipeGuard(): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Under pm2 the daemon's stdout/stderr are pipes to the pm2 God daemon. A
|
|
3
|
+
* broken pipe (a `pm2 logs` stream detaching, or a pm2 restart) would otherwise
|
|
4
|
+
* surface as an unhandled `'error'` on the stream and crash a daemon that has
|
|
5
|
+
* no uncaughtException trap. Swallow EPIPE on the std streams; re-throw anything
|
|
6
|
+
* else. (Borrowed from botmux's daemon bootstrap.)
|
|
7
|
+
*/
|
|
8
|
+
export function installStdioEpipeGuard() {
|
|
9
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
10
|
+
stream.on("error", (err) => {
|
|
11
|
+
if (err.code === "EPIPE")
|
|
12
|
+
return;
|
|
13
|
+
throw err;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/update.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type UpdateCheck } from "@rynx-ai/core";
|
|
2
|
+
export interface UpdateOptions {
|
|
3
|
+
check?: boolean;
|
|
4
|
+
/** With `check`, emit a typed {@link UpdateCheck} as JSON instead of a human line. */
|
|
5
|
+
json?: boolean;
|
|
6
|
+
version?: string;
|
|
7
|
+
resultFile?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Is `latest` a newer release than `current`? Numeric major.minor.patch (prerelease ignored). */
|
|
10
|
+
export declare function isNewer(latest: string, current: string): boolean;
|
|
11
|
+
/** Typed `--check` result a channel consumes via `--check --json`. */
|
|
12
|
+
export declare function buildUpdateCheck(current: string, latest: string | null): UpdateCheck;
|
|
13
|
+
/** Format the one-line, human-readable status emitted by a bare `--check`. */
|
|
14
|
+
export declare function checkStatusLine(current: string, latest: string | null): string;
|
|
15
|
+
/** Run `rynx update`. Returns a process exit code. */
|
|
16
|
+
export declare function runUpdate(opts: UpdateOptions): Promise<number>;
|