@rynx-ai/cli 0.1.11-beta.1 → 0.1.11-beta.3

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,314 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import path from "node:path";
4
+ import * as prompts from "@clack/prompts";
5
+ import { AGENT_RUNTIME_IDS, getRuntimeProfile, loadConfig, rynxConfigFile, } from "@rynx-ai/core";
6
+ import { inspectDaemonDiagnostics, prepareBundledPlugins, } from "@rynx-ai/daemon/setup-service";
7
+ import { fail } from "./errors.js";
8
+ export async function runSetupCommand(args) {
9
+ const options = parseSetupOptions(args);
10
+ const interactive = !options.nonInteractive &&
11
+ !options.hasConfigurationArguments &&
12
+ Boolean(process.stdin.isTTY && process.stdout.isTTY);
13
+ const existing = readRawConfig();
14
+ const created = !existsSync(rynxConfigFile());
15
+ const defaults = loadConfig({});
16
+ const next = {
17
+ ...existing,
18
+ HOST: existing.HOST ?? defaults.HOST,
19
+ PORT: existing.PORT ?? defaults.PORT,
20
+ LOG_LEVEL: existing.LOG_LEVEL ?? defaults.LOG_LEVEL,
21
+ DEFAULT_RUNTIME: existing.DEFAULT_RUNTIME ?? defaults.DEFAULT_RUNTIME,
22
+ };
23
+ if (interactive) {
24
+ if (!await collectInteractiveSetup(next))
25
+ return 0;
26
+ }
27
+ else {
28
+ if (options.defaultRuntime !== undefined)
29
+ next.DEFAULT_RUNTIME = options.defaultRuntime;
30
+ if (options.host !== undefined)
31
+ next.HOST = options.host;
32
+ if (options.port !== undefined)
33
+ next.PORT = options.port;
34
+ if (options.logLevel !== undefined)
35
+ next.LOG_LEVEL = options.logLevel;
36
+ }
37
+ validateSetupConfig(next);
38
+ const updated = ["HOST", "PORT", "LOG_LEVEL", "DEFAULT_RUNTIME"].filter((key) => existing[key] !== next[key]);
39
+ writeConfigAtomically(next);
40
+ const result = {
41
+ configPath: rynxConfigFile(),
42
+ created,
43
+ updated,
44
+ browser: {
45
+ action: options.browser,
46
+ status: "skipped",
47
+ },
48
+ plugins: {
49
+ status: "ready",
50
+ },
51
+ };
52
+ let failed = false;
53
+ try {
54
+ const plugins = await prepareBundledPlugins();
55
+ result.plugins = {
56
+ status: plugins.status,
57
+ changed: [...plugins.installed, ...plugins.updated],
58
+ };
59
+ }
60
+ catch (error) {
61
+ failed = true;
62
+ result.plugins = {
63
+ status: "error",
64
+ detail: errorMessage(error),
65
+ };
66
+ }
67
+ const diagnostics = await inspectDaemonDiagnostics();
68
+ if (!diagnostics.browser.supported) {
69
+ result.browser = { action: options.browser, status: "unsupported" };
70
+ }
71
+ else if (diagnostics.browser.installedVersion) {
72
+ result.browser = {
73
+ action: options.browser,
74
+ status: "ready",
75
+ version: diagnostics.browser.installedVersion,
76
+ };
77
+ }
78
+ else if (options.browser !== "skip") {
79
+ try {
80
+ const { createBrowserArtifactManagementService } = await import("@rynx-ai/daemon/browser-artifacts");
81
+ const installed = await createBrowserArtifactManagementService().install({ channel: "stable" }, options.json
82
+ ? {}
83
+ : { onProgress: (message) => console.error(message) });
84
+ result.browser = {
85
+ action: options.browser,
86
+ status: "ready",
87
+ version: installed.installed.version,
88
+ };
89
+ }
90
+ catch (error) {
91
+ failed = true;
92
+ result.browser = {
93
+ action: options.browser,
94
+ status: "error",
95
+ detail: errorMessage(error),
96
+ };
97
+ }
98
+ }
99
+ if (options.json) {
100
+ console.log(JSON.stringify(result, null, 2));
101
+ }
102
+ else if (interactive) {
103
+ prompts.log.success(`已写入 ${result.configPath}`);
104
+ prompts.log.info(`HOST=${next.HOST} PORT=${next.PORT} DEFAULT_RUNTIME=${next.DEFAULT_RUNTIME}`);
105
+ if (result.browser.status === "ready") {
106
+ prompts.log.success(`Session Browser ${result.browser.version ?? "ready"}`);
107
+ }
108
+ else if (result.browser.status === "error") {
109
+ prompts.log.warn(`Session Browser: ${result.browser.detail}`);
110
+ }
111
+ prompts.outro(failed ? "初始化完成,但存在失败项" : "配置完成");
112
+ }
113
+ else {
114
+ console.log(`Initialized ${result.configPath}`);
115
+ if (result.browser.status === "ready") {
116
+ console.log(`Browser ready${result.browser.version ? `: ${result.browser.version}` : ""}`);
117
+ }
118
+ }
119
+ return failed ? 1 : 0;
120
+ }
121
+ export async function runDoctorCommand(args) {
122
+ const json = args.includes("--json");
123
+ if (args.some((arg) => arg !== "--json")) {
124
+ fail(`doctor: unexpected argument ${args.find((arg) => arg !== "--json")}`);
125
+ }
126
+ let config;
127
+ let configError;
128
+ try {
129
+ config = loadConfig();
130
+ }
131
+ catch (error) {
132
+ configError = errorMessage(error);
133
+ }
134
+ const runtimes = AGENT_RUNTIME_IDS.map((id) => {
135
+ const binary = getRuntimeProfile(id).defaultBinary;
136
+ const probe = spawnSync(binary, ["--version"], {
137
+ stdio: "ignore",
138
+ timeout: 8_000,
139
+ });
140
+ return { id, installed: !probe.error && probe.status === 0 };
141
+ });
142
+ const daemon = await inspectDaemonDiagnostics();
143
+ const result = {
144
+ ok: configError === undefined && runtimes.some((runtime) => runtime.installed),
145
+ configPath: rynxConfigFile(),
146
+ config,
147
+ configError,
148
+ runtimes,
149
+ daemon,
150
+ };
151
+ if (json)
152
+ console.log(JSON.stringify(result, null, 2));
153
+ else {
154
+ console.log(`config: ${result.configPath}`);
155
+ if (configError)
156
+ console.error(`config: ${configError}`);
157
+ else
158
+ console.log(`default runtime: ${config.DEFAULT_RUNTIME}`);
159
+ for (const runtime of runtimes) {
160
+ console.log(`${runtime.id}: ${runtime.installed ? "installed" : "not installed"}`);
161
+ }
162
+ if (daemon.browser.supported) {
163
+ console.log(`browser: ${daemon.browser.installedVersion ?? daemon.browser.error ?? "not installed"}`);
164
+ }
165
+ else {
166
+ console.log("browser: unsupported");
167
+ }
168
+ }
169
+ return result.ok ? 0 : 1;
170
+ }
171
+ function parseSetupOptions(args) {
172
+ const options = {
173
+ nonInteractive: false,
174
+ json: false,
175
+ browser: "auto",
176
+ hasConfigurationArguments: false,
177
+ };
178
+ for (let index = 0; index < args.length; index += 1) {
179
+ const arg = args[index];
180
+ if (arg === "--non-interactive") {
181
+ options.nonInteractive = true;
182
+ continue;
183
+ }
184
+ if (arg === "--json") {
185
+ options.json = true;
186
+ options.nonInteractive = true;
187
+ continue;
188
+ }
189
+ if (arg === "--install-browser" || arg === "--skip-browser") {
190
+ if (options.browser !== "auto")
191
+ fail("setup: choose only one Browser action");
192
+ options.browser = arg === "--install-browser" ? "install" : "skip";
193
+ options.hasConfigurationArguments = true;
194
+ continue;
195
+ }
196
+ const value = args[index + 1];
197
+ if (!value || value.startsWith("--"))
198
+ fail(`setup: ${arg} requires a value`);
199
+ index += 1;
200
+ options.hasConfigurationArguments = true;
201
+ if (arg === "--default-runtime") {
202
+ if (!AGENT_RUNTIME_IDS.includes(value)) {
203
+ fail("setup: --default-runtime must be codex, traex, or claude");
204
+ }
205
+ options.defaultRuntime = value;
206
+ }
207
+ else if (arg === "--host") {
208
+ if (!value.trim() || value.length > 255)
209
+ fail("setup: --host is invalid");
210
+ options.host = value;
211
+ }
212
+ else if (arg === "--port") {
213
+ const port = Number(value);
214
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
215
+ fail("setup: --port must be an integer from 1 to 65535");
216
+ }
217
+ options.port = port;
218
+ }
219
+ else if (arg === "--log-level") {
220
+ if (!value.trim() || value.length > 32)
221
+ fail("setup: --log-level is invalid");
222
+ options.logLevel = value;
223
+ }
224
+ else {
225
+ fail(`setup: unknown option ${arg}`);
226
+ }
227
+ }
228
+ return options;
229
+ }
230
+ async function collectInteractiveSetup(config) {
231
+ prompts.intro("rynx setup");
232
+ const runtime = await prompts.select({
233
+ message: "Default runtime",
234
+ initialValue: config.DEFAULT_RUNTIME,
235
+ options: AGENT_RUNTIME_IDS.map((id) => ({ value: id, label: id })),
236
+ });
237
+ if (prompts.isCancel(runtime))
238
+ return cancelled();
239
+ config.DEFAULT_RUNTIME = runtime;
240
+ const port = await prompts.text({
241
+ message: "HTTP port",
242
+ initialValue: String(config.PORT),
243
+ validate: (value) => {
244
+ const parsed = Number(value);
245
+ return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535
246
+ ? undefined
247
+ : "Enter a port from 1 to 65535";
248
+ },
249
+ });
250
+ if (prompts.isCancel(port))
251
+ return cancelled();
252
+ config.PORT = Number(port);
253
+ const host = await prompts.text({
254
+ message: "Bind host",
255
+ initialValue: String(config.HOST),
256
+ });
257
+ if (prompts.isCancel(host))
258
+ return cancelled();
259
+ config.HOST = host;
260
+ const logLevel = await prompts.text({
261
+ message: "Log level",
262
+ initialValue: String(config.LOG_LEVEL),
263
+ });
264
+ if (prompts.isCancel(logLevel))
265
+ return cancelled();
266
+ config.LOG_LEVEL = logLevel;
267
+ return true;
268
+ }
269
+ function cancelled() {
270
+ prompts.cancel("Cancelled");
271
+ return false;
272
+ }
273
+ function validateSetupConfig(config) {
274
+ loadConfig({
275
+ HOST: String(config.HOST),
276
+ PORT: String(config.PORT),
277
+ LOG_LEVEL: String(config.LOG_LEVEL),
278
+ DEFAULT_RUNTIME: String(config.DEFAULT_RUNTIME),
279
+ });
280
+ }
281
+ function readRawConfig() {
282
+ try {
283
+ const parsed = JSON.parse(readFileSync(rynxConfigFile(), "utf8"));
284
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
285
+ ? parsed
286
+ : {};
287
+ }
288
+ catch {
289
+ return {};
290
+ }
291
+ }
292
+ function writeConfigAtomically(config) {
293
+ const file = rynxConfigFile();
294
+ const directory = path.dirname(file);
295
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
296
+ const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.tmp`);
297
+ let descriptor;
298
+ try {
299
+ descriptor = openSync(temporary, "wx", 0o600);
300
+ writeFileSync(descriptor, `${JSON.stringify(config, null, 2)}\n`, "utf8");
301
+ closeSync(descriptor);
302
+ descriptor = undefined;
303
+ renameSync(temporary, file);
304
+ chmodSync(file, 0o600);
305
+ }
306
+ finally {
307
+ if (descriptor !== undefined)
308
+ closeSync(descriptor);
309
+ rmSync(temporary, { force: true });
310
+ }
311
+ }
312
+ function errorMessage(error) {
313
+ return error instanceof Error ? error.message : String(error);
314
+ }
@@ -1,10 +1,12 @@
1
1
  export interface BuiltinSkill {
2
2
  name: string;
3
- directory: string;
4
- skillFile: string;
3
+ description: string;
5
4
  content: string;
5
+ full: boolean;
6
6
  }
7
7
  export declare function runSkillsCommand(args: readonly string[]): Promise<number>;
8
8
  export declare function listBuiltinSkills(): Promise<BuiltinSkill[]>;
9
- export declare function readBuiltinSkill(name: string): Promise<BuiltinSkill | null>;
10
- export declare function builtinSkillsDirectory(moduleUrl?: string, env?: NodeJS.ProcessEnv): string;
9
+ export declare function readBuiltinSkill(name: string, full?: boolean): Promise<BuiltinSkill | null>;
10
+ /** Resolve only inside the independently published @rynx-ai/cli package.
11
+ * The source and compiled module depths are identical (`src|dist/commands`). */
12
+ export declare function builtinSkillGuidesDirectory(moduleUrl?: string): string;
@@ -1,21 +1,26 @@
1
- import { existsSync } from "node:fs";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
  import { fail } from "./errors.js";
6
5
  const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
7
6
  const MAX_SKILL_BYTES = 256 * 1024;
7
+ const BUILTIN_SKILL_NAMES = ["browser", "emulator"];
8
8
  export async function runSkillsCommand(args) {
9
9
  const [subcommand, name] = args;
10
10
  const json = args.includes("--json");
11
11
  if (subcommand === "list") {
12
12
  const skills = await listBuiltinSkills();
13
13
  if (json) {
14
- console.log(JSON.stringify({ skills: skills.map(({ name: skillName }) => ({ name: skillName })) }, null, 2));
14
+ console.log(JSON.stringify({
15
+ topics: skills.map(({ name: topicName, description }) => ({
16
+ name: topicName,
17
+ description,
18
+ })),
19
+ }, null, 2));
15
20
  }
16
21
  else {
17
22
  for (const skill of skills)
18
- console.log(skill.name);
23
+ console.log(`${skill.name}: ${skill.description}`);
19
24
  }
20
25
  return 0;
21
26
  }
@@ -23,15 +28,17 @@ export async function runSkillsCommand(args) {
23
28
  if (!name || name.startsWith("--")) {
24
29
  fail("skills get: missing Builtin Skill name");
25
30
  }
26
- const skill = await readBuiltinSkill(name);
27
- if (!skill)
28
- fail(`Builtin Skill "${name}" was not found`);
31
+ const full = args.includes("--full");
32
+ const skill = await readBuiltinSkill(name, full);
33
+ if (!skill) {
34
+ const available = (await listBuiltinSkills()).map((entry) => entry.name).join(", ");
35
+ fail(`Unknown Skill guide "${name}". Available topics: ${available}`);
36
+ }
29
37
  if (json) {
30
38
  console.log(JSON.stringify({
31
39
  name: skill.name,
32
- directory: skill.directory,
33
- skillFile: skill.skillFile,
34
- content: skill.content,
40
+ full: skill.full,
41
+ markdown: skill.content,
35
42
  }, null, 2));
36
43
  }
37
44
  else {
@@ -44,21 +51,25 @@ export async function runSkillsCommand(args) {
44
51
  fail("skills: expected list or get <name>");
45
52
  }
46
53
  export async function listBuiltinSkills() {
47
- const known = ["rynx-cli"];
48
- const skills = await Promise.all(known.map((name) => readBuiltinSkill(name)));
49
- return skills.filter((skill) => skill !== null);
54
+ const guides = await Promise.all(BUILTIN_SKILL_NAMES.map((name) => readBuiltinSkill(name)));
55
+ return guides.filter((guide) => guide !== null);
50
56
  }
51
- export async function readBuiltinSkill(name) {
57
+ export async function readBuiltinSkill(name, full = false) {
52
58
  if (!SKILL_NAME_PATTERN.test(name))
53
59
  return null;
54
- const directory = path.join(builtinSkillsDirectory(), name);
55
- const skillFile = path.join(directory, "SKILL.md");
60
+ if (!BUILTIN_SKILL_NAMES.includes(name))
61
+ return null;
62
+ const guideFile = path.join(builtinSkillGuidesDirectory(), `${name}.md`);
56
63
  try {
57
- const content = await readFile(skillFile, "utf8");
64
+ const content = await readFile(guideFile, "utf8");
58
65
  if (Buffer.byteLength(content, "utf8") > MAX_SKILL_BYTES) {
59
- throw new Error(`Builtin Skill "${name}" exceeds ${MAX_SKILL_BYTES} bytes`);
66
+ throw new Error(`Builtin Skill guide "${name}" exceeds ${MAX_SKILL_BYTES} bytes`);
67
+ }
68
+ const metadata = parseGuideFrontmatter(content, guideFile);
69
+ if (metadata.name !== name) {
70
+ throw new Error(`Builtin Skill guide "${name}" declares name "${metadata.name}"`);
60
71
  }
61
- return { name, directory, skillFile, content };
72
+ return { name, description: metadata.description, content, full };
62
73
  }
63
74
  catch (error) {
64
75
  if (error.code === "ENOENT")
@@ -66,15 +77,17 @@ export async function readBuiltinSkill(name) {
66
77
  throw error;
67
78
  }
68
79
  }
69
- export function builtinSkillsDirectory(moduleUrl = import.meta.url, env = process.env) {
70
- const configured = env.RYNX_BUILTIN_SKILLS_DIR?.trim();
71
- if (configured && path.isAbsolute(configured) && existsSync(configured)) {
72
- return configured;
80
+ /** Resolve only inside the independently published @rynx-ai/cli package.
81
+ * The source and compiled module depths are identical (`src|dist/commands`). */
82
+ export function builtinSkillGuidesDirectory(moduleUrl = import.meta.url) {
83
+ return path.resolve(fileURLToPath(new URL("../../skill-guides/", moduleUrl)));
84
+ }
85
+ function parseGuideFrontmatter(markdown, sourcePath) {
86
+ const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(markdown);
87
+ const name = match && /^name:\s*(.+?)\s*$/m.exec(match[1])?.[1];
88
+ const description = match && /^description:\s*(.+?)\s*$/m.exec(match[1])?.[1];
89
+ if (!name || !description) {
90
+ throw new Error(`Builtin Skill guide must declare single-line name and description: ${sourcePath}`);
73
91
  }
74
- const packaged = fileURLToPath(new URL("../../skills/", moduleUrl));
75
- if (existsSync(packaged))
76
- return packaged;
77
- // Source-tree tests run before a package build has copied the canonical
78
- // artifact. Published/App builds always take the packaged branch above.
79
- return fileURLToPath(new URL("../../../../skills/", moduleUrl));
92
+ return { name, description: description.replace(/\s+/g, " ").trim() };
80
93
  }
@@ -0,0 +1,20 @@
1
+ export interface UpdateOptions {
2
+ check?: boolean;
3
+ json?: boolean;
4
+ version?: string;
5
+ }
6
+ export type UpdateCheck = {
7
+ status: "up_to_date";
8
+ current: string;
9
+ } | {
10
+ status: "behind";
11
+ current: string;
12
+ latest: string;
13
+ } | {
14
+ status: "error";
15
+ detail: string;
16
+ };
17
+ export declare function isNewer(latest: string, current: string): boolean;
18
+ export declare function buildUpdateCheck(current: string, latest: string | null): UpdateCheck;
19
+ export declare function checkStatusLine(current: string, latest: string | null): string;
20
+ export declare function runUpdate(options: UpdateOptions): Promise<number>;