u1s1-cli 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -377,6 +377,10 @@ export default async function (pi) {
377
377
  // ---- 汇总统计 ----
378
378
  let counts = {};
379
379
  let errors = 0;
380
+ // 工具调用被折叠后,这一行摘要是新手了解「AI 刚才做了什么」的唯一入口,内置工具名给人话
381
+ const TOOL_SUMMARY_LABELS: Record<string, string> = {
382
+ read: "读文件", write: "写文件", edit: "改文件", bash: "跑命令", grep: "搜内容", find: "找文件", ls: "列目录",
383
+ };
380
384
  pi.on("agent_start", async () => {
381
385
  counts = {};
382
386
  errors = 0;
@@ -392,14 +396,14 @@ export default async function (pi) {
392
396
  if (total === 0) return;
393
397
  const parts = Object.entries(counts)
394
398
  .sort((a, b) => b[1] - a[1])
395
- .map(([name, n]) => name + "×" + n);
399
+ .map(([name, n]) => (TOOL_SUMMARY_LABELS[name] ?? name) + "×" + n);
396
400
  if (errors > 0) parts.push("✗ " + errors);
397
401
  pi.appendEntry("tool-summary", { total, summary: parts.join(" · ") });
398
402
  });
399
403
  pi.registerEntryRenderer("tool-summary", (entry, _opts, theme) => {
400
404
  const d = entry.data;
401
405
  let text = theme.fg("muted", "⚙ ");
402
- text += theme.fg("toolTitle", theme.bold(d.total + " tool" + (d.total > 1 ? "s" : "")));
406
+ text += theme.fg("toolTitle", theme.bold(d.total + " 次工具调用"));
403
407
  text += theme.fg("dim", " · " + d.summary);
404
408
  return new Text(text, 0, 0);
405
409
  });
package/dist/api.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface MeResponse {
24
24
  packages?: MePackage[];
25
25
  /** 可领取的免费包:'first' 首月包 / 'renew' 年度包 / null 已有生效中的 */
26
26
  free_claim?: "first" | "renew" | null;
27
+ /** 支付通道是否开放;老网关没有该字段,按开放处理(保留充值入口) */
28
+ pay_enabled?: boolean;
27
29
  }
28
30
  export interface MePackage {
29
31
  id: number;
@@ -63,6 +65,9 @@ export interface ModelsResponse {
63
65
  /** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
64
66
  export declare class AuthError extends Error {
65
67
  }
68
+ /** 403:登录着但被服务端拒绝(封禁/停用/设备不受信任),重新登录也没用,进 TUI 每条消息都会失败。 */
69
+ export declare class AccessDeniedError extends Error {
70
+ }
66
71
  export declare function fetchModels(cfg: CliConfig): Promise<ModelsResponse>;
67
72
  /**
68
73
  * 会话内公告轮询用的轻量接口:免鉴权、服务端有缓存,回的就是 /v1/models 里的 announcement
package/dist/api.js CHANGED
@@ -57,6 +57,9 @@ export async function readJsonResponseCapped(resp, maxBytes = MAX_API_RESPONSE_B
57
57
  /** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
58
58
  export class AuthError extends Error {
59
59
  }
60
+ /** 403:登录着但被服务端拒绝(封禁/停用/设备不受信任),重新登录也没用,进 TUI 每条消息都会失败。 */
61
+ export class AccessDeniedError extends Error {
62
+ }
60
63
  /** 网关错误壳统一是 { error: { message } };解析不出来时回退到状态码提示。 */
61
64
  async function errorMessageFromResponse(resp, fallback) {
62
65
  const body = jsonRecord(await readJsonResponseCapped(resp, MAX_API_ERROR_BYTES).catch(() => null));
@@ -76,6 +79,8 @@ export async function fetchModels(cfg) {
76
79
  }
77
80
  if (resp.status === 401)
78
81
  throw new AuthError("登录已失效,请重新运行 u1s1 login");
82
+ if (resp.status === 403)
83
+ throw new AccessDeniedError(await errorMessageFromResponse(resp, "账号当前无法使用 u1s1"));
79
84
  if (!resp.ok)
80
85
  throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
81
86
  const body = await readJsonResponseCapped(resp);
package/dist/brand.js CHANGED
@@ -54,7 +54,7 @@ function starterLines(theme) {
54
54
  ` ${theme.fg("text", "「做一个自我介绍网页,做完帮我发布出去」")}`,
55
55
  ` ${theme.fg("text", "「做一个给朋友的生日祝福页面,要有点小动画」")}`,
56
56
  ` ${theme.fg("text", "「写一个把文件夹里照片按日期重命名的小工具」")}`,
57
- ` ${theme.fg("muted", "网页做好后,运行 u1s1 deploy --public 一键上线,拿到可分享的链接发给朋友")}`,
57
+ ` ${theme.fg("muted", "网页做好后,输入 /exit 回到终端,运行 u1s1 deploy --public 一键上线,拿到可分享的链接发给朋友")}`,
58
58
  ];
59
59
  }
60
60
  /**
@@ -55,10 +55,30 @@ export function extractRequestId(raw) {
55
55
  const id = payload?.err["request_id"];
56
56
  return typeof id === "string" && id.length > 0 && id.length <= 128 ? id : undefined;
57
57
  }
58
+ /**
59
+ * 非 JSON 的失败体(Cloudflare 5xx HTML 页、纯文本网关错误)原样直出就是
60
+ * `Error: 503 <!DOCTYPE html>…` 一大坨。只认得出状态码就给一句人话,
61
+ * 状态码数字保留在尾巴里,pi 的重试分类(5xx/429 可重试)不受影响。
62
+ */
63
+ function humanizeOpaqueError(raw) {
64
+ const status = /^\s*(\d{3})\b/.exec(raw)?.[1];
65
+ if (!status)
66
+ return undefined;
67
+ const opaque = /<\s*(?:!doctype|html)/i.test(raw) || raw.length > 400;
68
+ if (!opaque)
69
+ return undefined;
70
+ const n = Number(status);
71
+ const body = n >= 500
72
+ ? "服务暂时不可用,稍等再试;持续出现可看状态页 https://u1s1.io/status"
73
+ : n === 429
74
+ ? "请求太频繁,稍等再试"
75
+ : "请求被拒绝,重试仍失败可运行 u1s1 feedback 反馈";
76
+ return `${body} (HTTP ${status})`;
77
+ }
58
78
  export function humanizeModelError(raw, now = Date.now()) {
59
79
  const payload = parseErrorPayload(raw);
60
80
  if (!payload)
61
- return undefined;
81
+ return humanizeOpaqueError(raw);
62
82
  const { err, jsonStart } = payload;
63
83
  const message = typeof err["message"] === "string" ? err["message"].trim() : "";
64
84
  if (!message)
@@ -126,7 +126,7 @@ function parseFlags(args) {
126
126
  }
127
127
  function printHelp() {
128
128
  console.log("");
129
- console.log(" 把 Claude Code / Codex 的历史对话导入 u1s1");
129
+ console.log(" 把 Claude Code / Codex 的历史对话和技能导入 u1s1");
130
130
  console.log("");
131
131
  console.log(" 用法:");
132
132
  console.log(" u1s1 import 导入当前目录的对话");
@@ -136,6 +136,7 @@ function printHelp() {
136
136
  console.log(" u1s1 import --cwd 目录 指定项目目录(默认当前目录)");
137
137
  console.log(" u1s1 import --dry-run 只看会导哪些,不写盘");
138
138
  console.log(" u1s1 import --force 已经导过的也再导一遍");
139
+ console.log(" u1s1 import skills 导入 Claude Code / Codex 等工具的技能(SKILL.md),详见 u1s1 import skills -h");
139
140
  console.log("");
140
141
  console.log(" 导入后在对应项目里跑 u1s1,输入 /resume 就能看到。");
141
142
  console.log("");
@@ -255,6 +256,11 @@ function printImportSummary(summary) {
255
256
  console.log("");
256
257
  }
257
258
  export async function importCommand(args) {
259
+ if (args[0] === "skills" || args[0] === "skill") {
260
+ const { importSkillsCommand } = await import("./skills.js");
261
+ await importSkillsCommand(args.slice(1));
262
+ return;
263
+ }
258
264
  if (args.includes("-h") || args.includes("--help")) {
259
265
  printHelp();
260
266
  return;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `u1s1 import skills`: 把 Claude Code / Codex(以及任意 --from 目录)里的
3
+ * Agent Skills(SKILL.md)复制进 ~/.u1s1/agent/skills。pi 用同一套 SKILL.md
4
+ * 规范,所以只需要按 pi 自己的加载器校验一遍,合规的原样复制,不合规的列出来跳过。
5
+ */
6
+ export type SkillTool = "claude" | "codex" | "custom";
7
+ export interface SkillSource {
8
+ tool: SkillTool;
9
+ label: string;
10
+ dir: string;
11
+ scope: "user" | "project" | "custom";
12
+ }
13
+ export interface DiscoveredSkill {
14
+ name: string;
15
+ description: string;
16
+ tool: SkillTool;
17
+ label: string;
18
+ /** SKILL.md 所在目录;单文件技能时为该 .md 文件所在目录。 */
19
+ baseDir: string;
20
+ skillFile: string;
21
+ /** 单文件技能(skills 根目录直接放 foo.md)只复制那一个文件。 */
22
+ singleFile: boolean;
23
+ files: number;
24
+ bytes: number;
25
+ hints: string[];
26
+ }
27
+ export interface SkillProblem {
28
+ path: string;
29
+ reason: string;
30
+ }
31
+ export interface SkillImportRecord {
32
+ name: string;
33
+ tool: SkillTool;
34
+ sourcePath: string;
35
+ destPath: string;
36
+ importedAt: string;
37
+ }
38
+ export interface SkillImportIndex {
39
+ version: 1;
40
+ items: Record<string, SkillImportRecord>;
41
+ }
42
+ export interface SkillImportPlan {
43
+ pending: DiscoveredSkill[];
44
+ /** 已由本命令导过、这次不重复导(--force 可重导)。 */
45
+ alreadyImported: DiscoveredSkill[];
46
+ /** 目标目录已有同名技能但不是本命令导入的,不覆盖。 */
47
+ conflicts: DiscoveredSkill[];
48
+ /** 多个来源同名,只保留先发现的那个。 */
49
+ duplicates: DiscoveredSkill[];
50
+ }
51
+ export interface SkillImportResultItem {
52
+ name: string;
53
+ status: "imported" | "error";
54
+ destPath?: string;
55
+ detail?: string;
56
+ }
57
+ /** 超过这个体积的技能目录多半塞了数据集或依赖,先不动。 */
58
+ export declare const MAX_SKILL_BYTES: number;
59
+ export declare function skillsDestDir(): string;
60
+ export declare function skillIndexPath(): string;
61
+ /** Claude Code / Codex 各自约定的技能目录;项目级排在用户级前面,先发现的同名技能优先。 */
62
+ export declare function defaultSkillSources(cwd: string, home?: string): SkillSource[];
63
+ export declare function customSkillSource(dir: string): SkillSource;
64
+ /**
65
+ * 用 pi 自己的加载器扫一遍来源目录:能被 pi 认出来、且没有任何告警(名称/说明不合规)
66
+ * 的才算兼容,其他的进 problems 并带上 pi 给的原因。
67
+ */
68
+ export declare function discoverSkills(sources: SkillSource[]): {
69
+ skills: DiscoveredSkill[];
70
+ problems: SkillProblem[];
71
+ };
72
+ export declare function loadSkillIndex(path?: string): SkillImportIndex;
73
+ export declare function planSkillImport(skills: DiscoveredSkill[], opts: {
74
+ destDir: string;
75
+ index: SkillImportIndex;
76
+ force: boolean;
77
+ }): SkillImportPlan;
78
+ /** 复制到 <destDir>/<name>;单文件技能落成 <name>/SKILL.md,这样 --remove 只需删一个目录。 */
79
+ export declare function copySkill(skill: DiscoveredSkill, destDir: string): string;
80
+ export declare function runSkillImport(pending: DiscoveredSkill[], index: SkillImportIndex, destDir: string): SkillImportResultItem[];
81
+ /** 只删本命令导入过的技能,别人手放进去的同名目录不碰。 */
82
+ export declare function removeImportedSkill(name: string, index: SkillImportIndex, destDir: string): {
83
+ ok: true;
84
+ } | {
85
+ ok: false;
86
+ reason: string;
87
+ };
88
+ export declare function printSkillsHelp(): void;
89
+ export declare function importSkillsCommand(args: string[]): Promise<void>;
@@ -0,0 +1,492 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { loadSkillsFromDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
+ import { formatHomePath } from "../brand.js";
8
+ import { agentDir } from "../config.js";
9
+ import { asRecord, asString, formatBytes, listHomeClaudeDirs, oneLine, uniqueExistingDirs } from "./util.js";
10
+ import { readJsonIfExists, writeJson } from "./write.js";
11
+ const TOOL_LABEL = {
12
+ claude: "Claude Code",
13
+ codex: "Codex",
14
+ custom: "自定义目录",
15
+ };
16
+ const SCRIPT_EXTENSIONS = new Set([".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd", ".py", ".js", ".mjs", ".cjs", ".ts", ".rb", ".pl"]);
17
+ /** 超过这个体积的技能目录多半塞了数据集或依赖,先不动。 */
18
+ export const MAX_SKILL_BYTES = 20 * 1024 * 1024;
19
+ const MAX_SKILL_FILES = 2000;
20
+ const LARGE_SKILL_BYTES = 1024 * 1024;
21
+ export function skillsDestDir() {
22
+ return join(agentDir, "skills");
23
+ }
24
+ export function skillIndexPath() {
25
+ return join(agentDir, "imported-skills.json");
26
+ }
27
+ /** Claude Code / Codex 各自约定的技能目录;项目级排在用户级前面,先发现的同名技能优先。 */
28
+ export function defaultSkillSources(cwd, home = homedir()) {
29
+ const candidates = [
30
+ { tool: "claude", scope: "project", dir: join(cwd, ".claude", "skills") },
31
+ { tool: "codex", scope: "project", dir: join(cwd, ".agents", "skills") },
32
+ ...listHomeClaudeDirs(home).map((dir) => ({ tool: "claude", scope: "user", dir: join(dir, "skills") })),
33
+ { tool: "codex", scope: "user", dir: join(home, ".codex", "skills") },
34
+ { tool: "codex", scope: "user", dir: join(home, ".agents", "skills") },
35
+ ];
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const c of candidates) {
39
+ const [dir] = uniqueExistingDirs([c.dir]);
40
+ if (!dir || seen.has(dir))
41
+ continue;
42
+ seen.add(dir);
43
+ out.push({ tool: c.tool, label: TOOL_LABEL[c.tool], dir, scope: c.scope });
44
+ }
45
+ return out;
46
+ }
47
+ export function customSkillSource(dir) {
48
+ return { tool: "custom", label: TOOL_LABEL.custom, dir: resolve(dir), scope: "custom" };
49
+ }
50
+ function walkStats(dir, stats, depth = 0) {
51
+ if (stats.truncated || depth > 16)
52
+ return;
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ for (const entry of entries) {
61
+ if (entry.name === "node_modules" || entry.name === ".git")
62
+ continue;
63
+ const full = join(dir, entry.name);
64
+ let st;
65
+ try {
66
+ st = statSync(full);
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (st.isDirectory()) {
72
+ walkStats(full, stats, depth + 1);
73
+ }
74
+ else if (st.isFile()) {
75
+ stats.files += 1;
76
+ stats.bytes += st.size;
77
+ const dot = entry.name.lastIndexOf(".");
78
+ const ext = dot >= 0 ? entry.name.slice(dot).toLowerCase() : "";
79
+ const executable = process.platform !== "win32" && (st.mode & 0o111) !== 0;
80
+ if (SCRIPT_EXTENSIONS.has(ext) || executable)
81
+ stats.scripts += 1;
82
+ }
83
+ if (stats.files > MAX_SKILL_FILES || stats.bytes > MAX_SKILL_BYTES) {
84
+ stats.truncated = true;
85
+ return;
86
+ }
87
+ }
88
+ }
89
+ function skillHints(stats) {
90
+ const hints = [];
91
+ if (stats.scripts > 0)
92
+ hints.push(`含 ${stats.scripts} 个脚本,只导可信来源`);
93
+ if (stats.bytes > LARGE_SKILL_BYTES)
94
+ hints.push(`体积 ${formatBytes(stats.bytes)}`);
95
+ return hints;
96
+ }
97
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
98
+ /** 单文件技能(skills 根目录直接放 foo.md)没写 name 时 pi 会拿父目录名,导入后按文件名落目录更合理。 */
99
+ function singleFileSkillName(filePath, fallback) {
100
+ try {
101
+ const { frontmatter } = parseFrontmatter(readFileSync(filePath, "utf8"));
102
+ const declared = frontmatter["name"];
103
+ if (typeof declared === "string" && declared.trim())
104
+ return declared.trim();
105
+ }
106
+ catch {
107
+ return fallback;
108
+ }
109
+ return basename(filePath).replace(/\.md$/i, "");
110
+ }
111
+ /**
112
+ * 用 pi 自己的加载器扫一遍来源目录:能被 pi 认出来、且没有任何告警(名称/说明不合规)
113
+ * 的才算兼容,其他的进 problems 并带上 pi 给的原因。
114
+ */
115
+ export function discoverSkills(sources) {
116
+ const skills = [];
117
+ const problems = [];
118
+ for (const source of sources) {
119
+ let result;
120
+ try {
121
+ result = loadSkillsFromDir({ dir: source.dir, source: source.label });
122
+ }
123
+ catch (e) {
124
+ problems.push({ path: source.dir, reason: e instanceof Error ? e.message : String(e) });
125
+ continue;
126
+ }
127
+ const reasons = new Map();
128
+ for (const diag of result.diagnostics) {
129
+ if (diag.type === "collision")
130
+ continue;
131
+ const key = diag.path ?? source.dir;
132
+ reasons.set(key, [...(reasons.get(key) ?? []), diag.message]);
133
+ }
134
+ for (const [path, list] of reasons)
135
+ problems.push({ path, reason: list.join("; ") });
136
+ for (const skill of result.skills) {
137
+ if (reasons.has(skill.filePath))
138
+ continue;
139
+ const singleFile = basename(skill.filePath) !== "SKILL.md";
140
+ const name = singleFile ? singleFileSkillName(skill.filePath, skill.name) : skill.name;
141
+ if (!SKILL_NAME_RE.test(name) || name.length > 64) {
142
+ problems.push({ path: skill.filePath, reason: `技能名「${name}」不合规:只能用小写字母、数字和连字符` });
143
+ continue;
144
+ }
145
+ const stats = { files: 0, bytes: 0, scripts: 0, truncated: false };
146
+ if (singleFile) {
147
+ try {
148
+ stats.files = 1;
149
+ stats.bytes = statSync(skill.filePath).size;
150
+ }
151
+ catch {
152
+ stats.files = 0;
153
+ }
154
+ }
155
+ else {
156
+ walkStats(skill.baseDir, stats);
157
+ }
158
+ if (stats.truncated) {
159
+ problems.push({
160
+ path: singleFile ? skill.filePath : skill.baseDir,
161
+ reason: `技能目录太大(超过 ${formatBytes(MAX_SKILL_BYTES)} 或 ${MAX_SKILL_FILES} 个文件),先跳过`,
162
+ });
163
+ continue;
164
+ }
165
+ skills.push({
166
+ name,
167
+ description: skill.description,
168
+ tool: source.tool,
169
+ label: source.label,
170
+ baseDir: skill.baseDir,
171
+ skillFile: skill.filePath,
172
+ singleFile,
173
+ files: stats.files,
174
+ bytes: stats.bytes,
175
+ hints: skillHints(stats),
176
+ });
177
+ }
178
+ }
179
+ return { skills, problems };
180
+ }
181
+ export function loadSkillIndex(path = skillIndexPath()) {
182
+ const rec = asRecord(readJsonIfExists(path));
183
+ const items = asRecord(rec?.["items"]);
184
+ const out = { version: 1, items: {} };
185
+ if (!items)
186
+ return out;
187
+ for (const [key, value] of Object.entries(items)) {
188
+ const item = asRecord(value);
189
+ if (!item)
190
+ continue;
191
+ const name = asString(item["name"]);
192
+ const tool = asString(item["tool"]);
193
+ const sourcePath = asString(item["sourcePath"]);
194
+ const destPath = asString(item["destPath"]);
195
+ const importedAt = asString(item["importedAt"]);
196
+ if (!name || !sourcePath || !destPath || !importedAt)
197
+ continue;
198
+ if (tool !== "claude" && tool !== "codex" && tool !== "custom")
199
+ continue;
200
+ out.items[key] = { name, tool, sourcePath, destPath, importedAt };
201
+ }
202
+ return out;
203
+ }
204
+ export function planSkillImport(skills, opts) {
205
+ const plan = { pending: [], alreadyImported: [], conflicts: [], duplicates: [] };
206
+ const seen = new Set();
207
+ for (const skill of skills) {
208
+ if (seen.has(skill.name)) {
209
+ plan.duplicates.push(skill);
210
+ continue;
211
+ }
212
+ seen.add(skill.name);
213
+ const dest = join(opts.destDir, skill.name);
214
+ const record = opts.index.items[skill.name];
215
+ const destExists = existsSync(dest);
216
+ if (record && destExists && !opts.force) {
217
+ plan.alreadyImported.push(skill);
218
+ continue;
219
+ }
220
+ if (!record && destExists) {
221
+ plan.conflicts.push(skill);
222
+ continue;
223
+ }
224
+ plan.pending.push(skill);
225
+ }
226
+ return plan;
227
+ }
228
+ /** 复制到 <destDir>/<name>;单文件技能落成 <name>/SKILL.md,这样 --remove 只需删一个目录。 */
229
+ export function copySkill(skill, destDir) {
230
+ const dest = join(destDir, skill.name);
231
+ if (resolve(skill.baseDir) === resolve(dest))
232
+ throw new Error("来源就是目标目录");
233
+ mkdirSync(destDir, { recursive: true });
234
+ rmSync(dest, { recursive: true, force: true });
235
+ if (skill.singleFile) {
236
+ mkdirSync(dest, { recursive: true });
237
+ cpSync(skill.skillFile, join(dest, "SKILL.md"));
238
+ }
239
+ else {
240
+ cpSync(skill.baseDir, dest, {
241
+ recursive: true,
242
+ dereference: true,
243
+ filter: (src) => {
244
+ const name = basename(src);
245
+ return name !== "node_modules" && name !== ".git";
246
+ },
247
+ });
248
+ }
249
+ return dest;
250
+ }
251
+ export function runSkillImport(pending, index, destDir) {
252
+ const items = [];
253
+ for (const skill of pending) {
254
+ try {
255
+ const destPath = copySkill(skill, destDir);
256
+ index.items[skill.name] = {
257
+ name: skill.name,
258
+ tool: skill.tool,
259
+ sourcePath: skill.singleFile ? skill.skillFile : skill.baseDir,
260
+ destPath,
261
+ importedAt: new Date().toISOString(),
262
+ };
263
+ items.push({ name: skill.name, status: "imported", destPath });
264
+ }
265
+ catch (e) {
266
+ items.push({ name: skill.name, status: "error", detail: e instanceof Error ? e.message : String(e) });
267
+ }
268
+ }
269
+ return items;
270
+ }
271
+ /** 只删本命令导入过的技能,别人手放进去的同名目录不碰。 */
272
+ export function removeImportedSkill(name, index, destDir) {
273
+ const record = index.items[name];
274
+ if (!record)
275
+ return { ok: false, reason: "不是 u1s1 import skills 导入的技能,请手动处理" };
276
+ const expected = join(destDir, name);
277
+ if (resolve(record.destPath) !== resolve(expected)) {
278
+ return { ok: false, reason: `记录的位置 ${record.destPath} 与技能目录不一致,请手动处理` };
279
+ }
280
+ try {
281
+ if (existsSync(expected) && lstatSync(expected).isDirectory())
282
+ rmSync(expected, { recursive: true, force: true });
283
+ }
284
+ catch (e) {
285
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
286
+ }
287
+ delete index.items[name];
288
+ return { ok: true };
289
+ }
290
+ function parseSkillsArgs(args) {
291
+ const opts = {
292
+ cwd: process.cwd(),
293
+ from: [],
294
+ tools: undefined,
295
+ dryRun: false,
296
+ force: false,
297
+ yes: false,
298
+ list: false,
299
+ remove: [],
300
+ };
301
+ const needValue = (flag, i) => {
302
+ const next = args[i + 1];
303
+ if (!next || next.startsWith("-")) {
304
+ console.error(` ${flag} 后面要跟一个值`);
305
+ process.exit(1);
306
+ }
307
+ return next;
308
+ };
309
+ for (let i = 0; i < args.length; i++) {
310
+ const a = args[i];
311
+ if (a === "--dry-run")
312
+ opts.dryRun = true;
313
+ else if (a === "--force" || a === "--update")
314
+ opts.force = true;
315
+ else if (a === "-y" || a === "--yes")
316
+ opts.yes = true;
317
+ else if (a === "--list")
318
+ opts.list = true;
319
+ else if (a === "--from")
320
+ opts.from.push(needValue(a, i++));
321
+ else if (a === "--cwd")
322
+ opts.cwd = needValue(a, i++);
323
+ else if (a === "--remove")
324
+ opts.remove.push(needValue(a, i++));
325
+ else if (a.startsWith("-")) {
326
+ console.error(` 不认识参数 ${a}`);
327
+ process.exit(1);
328
+ }
329
+ else {
330
+ const tool = parseToolArg(a);
331
+ opts.tools = opts.tools ? [...opts.tools, tool] : [tool];
332
+ }
333
+ }
334
+ return opts;
335
+ }
336
+ function parseToolArg(raw) {
337
+ const aliases = {
338
+ claude: "claude",
339
+ "claude-code": "claude",
340
+ cc: "claude",
341
+ anthropic: "claude",
342
+ codex: "codex",
343
+ openai: "codex",
344
+ };
345
+ const tool = aliases[raw.toLowerCase()];
346
+ if (!tool) {
347
+ console.error(` 不认识来源「${raw}」,可选: claude / codex;其他工具用 --from 目录`);
348
+ process.exit(1);
349
+ }
350
+ return tool;
351
+ }
352
+ export function printSkillsHelp() {
353
+ console.log("");
354
+ console.log(" 把 Claude Code / Codex 等工具的技能(SKILL.md)导入 u1s1");
355
+ console.log("");
356
+ console.log(" 用法:");
357
+ console.log(" u1s1 import skills 自动找本机 Claude Code / Codex 的技能");
358
+ console.log(" u1s1 import skills claude 只导 Claude Code 的");
359
+ console.log(" u1s1 import skills codex 只导 Codex 的");
360
+ console.log(" u1s1 import skills --from 目录 其他工具:指定放 SKILL.md 的目录(可重复)");
361
+ console.log(" u1s1 import skills --cwd 目录 项目级技能按这个目录找(默认当前目录)");
362
+ console.log(" u1s1 import skills --dry-run 只看会导哪些,不写盘");
363
+ console.log(" u1s1 import skills --update 已导过的重新复制一遍(同 --force)");
364
+ console.log(" u1s1 import skills --list 看已导入的技能");
365
+ console.log(" u1s1 import skills --remove 名称 删掉某个导入的技能");
366
+ console.log("");
367
+ console.log(` 技能会复制到 ${formatHomePath(skillsDestDir())},下次进对话就能用:`);
368
+ console.log(" 模型会按说明自动调用,也可以在会话里输入 /skill:名称 手动调用。");
369
+ console.log("");
370
+ }
371
+ async function confirm(question) {
372
+ if (!input.isTTY || !output.isTTY)
373
+ return true;
374
+ const rl = createInterface({ input, output });
375
+ try {
376
+ const ans = (await rl.question(question)).trim().toLowerCase();
377
+ return ans === "" || ans === "y" || ans === "yes" || ans === "是";
378
+ }
379
+ finally {
380
+ rl.close();
381
+ }
382
+ }
383
+ function describeSource(skill) {
384
+ return formatHomePath(skill.singleFile ? skill.skillFile : skill.baseDir);
385
+ }
386
+ function printList(index) {
387
+ const items = Object.values(index.items).sort((a, b) => a.name.localeCompare(b.name));
388
+ console.log("");
389
+ if (items.length === 0) {
390
+ console.log(" 还没导入过技能。跑 u1s1 import skills 试试。");
391
+ console.log("");
392
+ return;
393
+ }
394
+ console.log(` 已导入 ${items.length} 个技能(${formatHomePath(skillsDestDir())}):`);
395
+ for (const item of items) {
396
+ const missing = existsSync(item.destPath) ? "" : "(目录已不在)";
397
+ console.log(` · ${item.name} [${TOOL_LABEL[item.tool]}] ${missing}`);
398
+ console.log(` 来自 ${formatHomePath(item.sourcePath)}`);
399
+ }
400
+ console.log("");
401
+ }
402
+ function printPreview(skills, plan, problems, sources) {
403
+ console.log("");
404
+ console.log(` 在 ${sources.length} 个目录里找到 ${skills.length} 个技能:`);
405
+ for (const source of sources)
406
+ console.log(` ${formatHomePath(source.dir)} [${source.label}]`);
407
+ console.log("");
408
+ for (const skill of plan.pending) {
409
+ console.log(` + ${skill.name} [${skill.label}] ${oneLine(skill.description, 60)}`);
410
+ console.log(` ${describeSource(skill)}${skill.hints.length ? ` ⚠ ${skill.hints.join(";")}` : ""}`);
411
+ }
412
+ for (const skill of plan.alreadyImported)
413
+ console.log(` · ${skill.name} 已导过(加 --update 重导)`);
414
+ for (const skill of plan.conflicts) {
415
+ console.log(` · ${skill.name} 目标目录已有同名技能(不是本命令导的),不覆盖`);
416
+ }
417
+ for (const skill of plan.duplicates)
418
+ console.log(` · ${skill.name} 与前面同名,只保留先发现的(${describeSource(skill)})`);
419
+ for (const problem of problems) {
420
+ console.log(` × ${formatHomePath(problem.path)} 不兼容,跳过: ${oneLine(problem.reason, 80)}`);
421
+ }
422
+ }
423
+ export async function importSkillsCommand(args) {
424
+ if (args.includes("-h") || args.includes("--help")) {
425
+ printSkillsHelp();
426
+ return;
427
+ }
428
+ const opts = parseSkillsArgs(args);
429
+ const destDir = skillsDestDir();
430
+ const index = loadSkillIndex();
431
+ if (opts.remove.length > 0) {
432
+ for (const name of opts.remove) {
433
+ const result = removeImportedSkill(name, index, destDir);
434
+ if (result.ok)
435
+ console.log(` ✓ 已删除技能 ${name}`);
436
+ else
437
+ console.log(` × ${name}: ${result.reason}`);
438
+ }
439
+ writeJson(skillIndexPath(), index);
440
+ return;
441
+ }
442
+ if (opts.list) {
443
+ printList(index);
444
+ return;
445
+ }
446
+ const sources = [
447
+ ...opts.from.map(customSkillSource),
448
+ ...defaultSkillSources(opts.cwd).filter((s) => !opts.tools || opts.tools.includes(s.tool)),
449
+ ];
450
+ const missingFrom = opts.from.filter((dir) => !existsSync(dir));
451
+ for (const dir of missingFrom)
452
+ console.log(` --from 目录不存在: ${dir}`);
453
+ if (sources.length === 0) {
454
+ console.log("");
455
+ console.log(" 没找到 Claude Code / Codex 的技能目录(~/.claude/skills、~/.codex/skills、项目里的 .claude/skills 或 .agents/skills)。");
456
+ console.log(" 其他工具的技能可以用 --from 指定目录,只要里面有 SKILL.md。");
457
+ console.log("");
458
+ return;
459
+ }
460
+ const { skills, problems } = discoverSkills(sources);
461
+ const plan = planSkillImport(skills, { destDir, index, force: opts.force });
462
+ printPreview(skills, plan, problems, sources);
463
+ if (plan.pending.length === 0) {
464
+ console.log("");
465
+ console.log(skills.length === 0 ? " 这些目录里没有能用的技能。" : " 没有新的可导。想重导一遍就加 --update。");
466
+ console.log("");
467
+ return;
468
+ }
469
+ if (opts.dryRun) {
470
+ console.log("");
471
+ console.log(` 预演结束,以上 ${plan.pending.length} 个还没真正导入。`);
472
+ console.log("");
473
+ return;
474
+ }
475
+ if (!opts.yes && !(await confirm(` 导入这 ${plan.pending.length} 个技能?(回车=好 / n=取消) `))) {
476
+ console.log(" 已取消。");
477
+ return;
478
+ }
479
+ const results = runSkillImport(plan.pending, index, destDir);
480
+ writeJson(skillIndexPath(), index);
481
+ const imported = results.filter((r) => r.status === "imported").length;
482
+ const errors = results.filter((r) => r.status === "error");
483
+ console.log("");
484
+ console.log(` ✓ 导入 ${imported} 个技能到 ${formatHomePath(destDir)}${errors.length ? `,失败 ${errors.length}` : ""}。`);
485
+ for (const item of errors)
486
+ console.log(` × ${item.name}: ${item.detail ?? "失败"}`);
487
+ if (imported > 0) {
488
+ console.log(" 下次进 u1s1 对话就能用:模型会按说明自动调用,或输入 /skill:名称 手动调用。");
489
+ console.log(` 管理: u1s1 import skills --list / --update / --remove 名称`);
490
+ }
491
+ console.log("");
492
+ }
@@ -2,7 +2,7 @@ export declare const MAX_TOOL_RESULT_CHARS = 80000;
2
2
  export declare const MAX_TEXT_CHARS = 200000;
3
3
  export declare const PREVIEW_TITLE_CHARS = 48;
4
4
  export declare function resolveExistingDir(path: string): string | undefined;
5
- export declare function listHomeClaudeDirs(): string[];
5
+ export declare function listHomeClaudeDirs(home?: string): string[];
6
6
  export declare function uniqueExistingDirs(paths: Array<string | undefined>): string[];
7
7
  export declare function encodeClaudeProjectDir(cwd: string): string;
8
8
  export declare function samePath(a: string, b: string): boolean;
@@ -15,8 +15,7 @@ export function resolveExistingDir(path) {
15
15
  return undefined;
16
16
  }
17
17
  }
18
- export function listHomeClaudeDirs() {
19
- const home = homedir();
18
+ export function listHomeClaudeDirs(home = homedir()) {
20
19
  const out = [];
21
20
  try {
22
21
  for (const name of readdirSync(home)) {
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { registerLoopCommand } from "./loop.js";
9
9
  import { ensureSearchTools } from "./search-tools.js";
10
10
  import { ensureUsableShell } from "./shell-doctor.js";
11
11
  import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
12
- import { AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
12
+ import { AccessDeniedError, AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
13
13
  import { ensureSigningProxy } from "./device-auth.js";
14
14
  import { installChildEnvGuard } from "./secret-env.js";
15
15
  import { DEPLOY_HINT_TEXT, markNudge, shouldShowDeployHint } from "./nudges.js";
@@ -93,6 +93,7 @@ function installPendingUpdate() {
93
93
  return;
94
94
  const { latest, installCmd } = pendingUpdate;
95
95
  console.log(`\n⬆ 正在更新到 v${latest}…`);
96
+ console.log(" (可能需要几分钟,期间请不要关闭窗口或按 Ctrl+C,中途打断会装出半截)");
96
97
  try {
97
98
  execSync(installCmd, { stdio: "inherit" });
98
99
  console.log(`✅ 已更新到 v${latest},下次运行 u1s1 生效。`);
@@ -200,6 +201,12 @@ async function runAgent(cfg, args) {
200
201
  return undefined;
201
202
  });
202
203
  }
204
+ else if (e instanceof AccessDeniedError) {
205
+ // 封禁/停用/设备不受信任:进 TUI 后每条消息都会失败,不如现在就把原因说清楚并退出
206
+ console.error(` ${e.message}`);
207
+ console.error(" 这个账号当前无法使用 u1s1。如有疑问可发邮件到 contact@u1s1.io,或在 https://u1s1.io/dashboard 提交工单。");
208
+ process.exit(1);
209
+ }
203
210
  else {
204
211
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
205
212
  }
@@ -529,7 +536,7 @@ async function run() {
529
536
  console.log(" u1s1 deploy list 查看已发布的站点");
530
537
  console.log(" u1s1 deploy remove 删除已发布的站点");
531
538
  console.log(" u1s1 feedback \"一句话\" 反馈问题或建议(自动建工单,--bug/--question…)");
532
- console.log(" u1s1 import 导入历史会话");
539
+ console.log(" u1s1 import 导入历史会话(import skills 导入技能)");
533
540
  console.log(" u1s1 bench 模型编码能力评测");
534
541
  console.log(" u1s1 --version 查看版本");
535
542
  console.log("");
@@ -543,6 +550,12 @@ async function run() {
543
550
  process.exitCode = 1;
544
551
  return;
545
552
  }
553
+ // `u1s1 login --help` 不该真的去登录、`logout --help` 不该真的退出:先看是不是在问用法
554
+ if (cmd && SIMPLE_COMMAND_HELP[cmd] && (args[1] === "--help" || args[1] === "-h")) {
555
+ for (const line of SIMPLE_COMMAND_HELP[cmd])
556
+ console.log(line);
557
+ return;
558
+ }
546
559
  if (cmd === "deploy") {
547
560
  const { deployCommand, printDeployHelp } = await import("./deploy.js");
548
561
  // --help 不该先被拽去登录
@@ -622,8 +635,9 @@ async function run() {
622
635
  }
623
636
  // 拼错的子命令(如 `u1s1 depoly`)不该被静默当成第一条消息发给模型白烧 token。
624
637
  // 只拦「单个短英文词 + 与已知命令编辑距离很近」的情况,正常 prompt 不受影响。
638
+ // `u1s1 delpoy list` 这类带参数的拼错也拦;长句(≥4 个词)才当 prompt 放行
625
639
  if (cmd
626
- && args.length === 1
640
+ && args.length <= 3
627
641
  && /^[a-z][a-z0-9-]{1,15}$/.test(cmd)
628
642
  && !KNOWN_COMMANDS.includes(cmd)) {
629
643
  const near = KNOWN_COMMANDS.find((k) => editDistance(k, cmd) <= (k.length <= 4 ? 1 : 2));
@@ -642,7 +656,28 @@ async function run() {
642
656
  // autoUpdate 开着且启动时发现了新版:现在装(TUI 已退出,原地重铺安全)
643
657
  installPendingUpdate();
644
658
  }
645
- const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "bench", "feedback", "help", "web"];
659
+ const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "bench", "feedback", "help"];
660
+ /** 没有自己 --help 处理的简单子命令:问用法时只打印说明,不执行任何动作。 */
661
+ const SIMPLE_COMMAND_HELP = {
662
+ login: [
663
+ "用法: u1s1 login",
664
+ " 在浏览器里批准这台设备登录 u1s1;没有账号会引导注册。",
665
+ " 登录信息保存在 ~/.u1s1/config.json,退出登录用 u1s1 logout。",
666
+ ],
667
+ logout: [
668
+ "用法: u1s1 logout",
669
+ " 退出本机登录,并注销这台设备在服务端的授权(连不上服务器时只清本机)。",
670
+ ],
671
+ usage: [
672
+ "用法: u1s1 usage",
673
+ " 查看剩余额度:免费包、已购用量包与余额。会话内也可输入 /usage。",
674
+ ],
675
+ update: [
676
+ "用法: u1s1 update",
677
+ " 检查并升级到最新版;npm 安装的走 npm,便携版原地自更新。",
678
+ " 不想每次退出自动更新:在 ~/.u1s1/agent/settings.json 设 \"autoUpdate\": false",
679
+ ],
680
+ };
646
681
  /** 经典 Levenshtein,命令名都很短,O(nm) 足够。 */
647
682
  function editDistance(a, b) {
648
683
  const dp = Array.from({ length: a.length + 1 }, (_, i) => i);
package/dist/login.js CHANGED
@@ -149,6 +149,8 @@ export async function login(keyArg) {
149
149
  printConsoleBanner(VERSION);
150
150
  const origin = apiOrigin(cfg);
151
151
  const failure = {};
152
+ // 慢网下这一步最长 15 秒,横幅之后一片安静像是卡死;先说一声在连
153
+ console.log(" 正在连接 u1s1 服务器…");
152
154
  const start = await startDeviceLogin(origin, failure);
153
155
  if (!start) {
154
156
  if (failure.message) {
package/dist/nudges.d.ts CHANGED
@@ -18,4 +18,4 @@ export declare function hasNudge(key: NudgeKey, file?: string): boolean;
18
18
  export declare function markNudge(key: NudgeKey, file?: string): void;
19
19
  /** 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。 */
20
20
  export declare function shouldShowDeployHint(file?: string): boolean;
21
- export declare const DEPLOY_HINT_TEXT = "\u505A\u597D\u4E86\u60F3\u7ED9\u670B\u53CB\u770B\uFF1F\u8FD0\u884C u1s1 deploy \u4E00\u952E\u4E0A\u7EBF";
21
+ export declare const DEPLOY_HINT_TEXT = "\u505A\u597D\u4E86\u60F3\u7ED9\u670B\u53CB\u770B\uFF1F\u8F93\u5165 /exit \u56DE\u5230\u7EC8\u7AEF,\u8FD0\u884C u1s1 deploy \u4E00\u952E\u4E0A\u7EBF";
package/dist/nudges.js CHANGED
@@ -37,4 +37,4 @@ export function shouldShowDeployHint(file = nudgesFile) {
37
37
  const state = readNudges(file);
38
38
  return state.deploy_hint_shown === undefined && state.deploy_done === undefined;
39
39
  }
40
- export const DEPLOY_HINT_TEXT = "做好了想给朋友看?运行 u1s1 deploy 一键上线";
40
+ export const DEPLOY_HINT_TEXT = "做好了想给朋友看?输入 /exit 回到终端,运行 u1s1 deploy 一键上线";
package/dist/update.js CHANGED
@@ -147,6 +147,7 @@ async function portableSelfUpdate(latest) {
147
147
  process.exit(0);
148
148
  }
149
149
  console.log(`正在下载安装脚本并升级到 v${latest}…`);
150
+ console.log("(可能需要几分钟,期间请不要关闭窗口)");
150
151
  let script;
151
152
  try {
152
153
  const download = await downloadInstallScript();
package/dist/usage.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { type MeResponse } from "./api.js";
2
2
  export declare const TOPUP_URL = "https://u1s1.io/dashboard#usage-topup-card";
3
- /** 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。 */
4
- export declare function usageCtaLines(): string[];
3
+ /**
4
+ * 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。
5
+ * 支付通道关闭时充值是死路(仪表盘会报「支付通道未开放」),改说即将上线并指向
6
+ * 仪表盘的加量包/打卡入口;老网关没有 pay_enabled 字段,按开放处理。
7
+ */
8
+ export declare function usageCtaLines(me?: Pick<MeResponse, "pay_enabled">): string[];
5
9
  /** 额度报告正文(不含首尾空行),终端 `u1s1 usage` 和会话内 /usage 共用。 */
6
10
  export declare function usageReportLines(me: MeResponse): string[];
7
11
  /** 会话内 /usage 用:拉取额度并打包成 entry data,错误转成中文提示而不是抛出。 */
package/dist/usage.js CHANGED
@@ -94,7 +94,7 @@ function packageUsageLines(me, tokensPerUsd) {
94
94
  lines.push(" 其他模型(如 DeepSeek V4 Pro)不走免费包,可用余额或全模型包 → https://u1s1.io/dashboard");
95
95
  lines.push(" Token 数按默认模型单价折算,为约数;用更贵的模型时消耗更快。");
96
96
  lines.push("");
97
- lines.push(...usageCtaLines());
97
+ lines.push(...usageCtaLines(me));
98
98
  return lines;
99
99
  }
100
100
  function legacyUsageLines(me, tokensPerUsd) {
@@ -118,15 +118,22 @@ function legacyUsageLines(me, tokensPerUsd) {
118
118
  lines.push(` 本月成本 $${me.mtd_usd.toFixed(2)}`);
119
119
  }
120
120
  lines.push("");
121
- lines.push(...usageCtaLines());
121
+ lines.push(...usageCtaLines(me));
122
122
  return lines;
123
123
  }
124
124
  export const TOPUP_URL = "https://u1s1.io/dashboard#usage-topup-card";
125
- /** 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。 */
126
- export function usageCtaLines() {
125
+ /**
126
+ * 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。
127
+ * 支付通道关闭时充值是死路(仪表盘会报「支付通道未开放」),改说即将上线并指向
128
+ * 仪表盘的加量包/打卡入口;老网关没有 pay_enabled 字段,按开放处理。
129
+ */
130
+ export function usageCtaLines(me) {
131
+ const payClosed = me?.pay_enabled === false;
127
132
  return [
128
133
  " 免费额度每天自动恢复;邀请朋友双方各得永久加量 → https://u1s1.io/dashboard",
129
- ` 额度不够用?充值 → ${TOPUP_URL}`,
134
+ payClosed
135
+ ? " 充值通道即将开放;额度不够先到仪表盘领加量包、每日打卡 → https://u1s1.io/dashboard"
136
+ : ` 额度不够用?充值 → ${TOPUP_URL}`,
130
137
  ];
131
138
  }
132
139
  /** 额度报告正文(不含首尾空行),终端 `u1s1 usage` 和会话内 /usage 共用。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -271,6 +271,156 @@ function patchCompactUi() {
271
271
  }
272
272
  }
273
273
 
274
+ // ---- TUI 零散英文汉化(与 pnpm coding-agent patch 同步,patch-pi.test.ts 校验双轨一致) ----
275
+ // 新手最常撞见的几句:Esc 中断后的 "Operation aborted"、每条报错前缀 "Error:"、
276
+ // 每轮都在转的 "Working..."、选择器脚注的 "navigate/select/cancel"、/model 列表只显示 id
277
+ // 看不到免费/价格提示、/settings 整页英文。行为不变,只换文案;选择器改为渲染 name(带提示)。
278
+ const ASSISTANT_TEXT_REPLACEMENTS = [
279
+ ['theme.fg("error", "Response was truncated before completion.")', 'theme.fg("error", "回复超出单次长度上限被截断了,输入「继续」可以接着写。")'],
280
+ [': "Operation aborted";', ': "已中断";'],
281
+ ['const errorMsg = message.errorMessage || "Unknown error";', 'const errorMsg = message.errorMessage || "未知错误(反复出现可运行 u1s1 feedback 反馈)";'],
282
+ ['theme.fg("error", `Error: ${errorMsg}`)', 'theme.fg("error", `出错:${errorMsg}`)'],
283
+ ];
284
+ const WORKING_TEXT_REPLACEMENTS = [
285
+ ['defaultWorkingMessage = "Working...";', 'defaultWorkingMessage = "思考中…";'],
286
+ ['defaultHiddenThinkingLabel = "Thinking...";', 'defaultHiddenThinkingLabel = "思考中…";'],
287
+ ];
288
+ // 脚注提示(↑↓ navigate · enter select · esc cancel …)在十几个组件里各写各的,
289
+ // 在 keyHint/rawKeyHint 出口处统一查表翻译,新组件加进来也能覆盖到。
290
+ const KEY_HINT_REPLACEMENTS = [
291
+ [
292
+ 'export function keyHint(keybinding, description) {',
293
+ 'const U1S1_HINTS = { cancel: "取消", "to cancel": "取消", "to cancel,": "取消,", navigate: "上下选择", submit: "提交", "to submit": "提交", save: "保存", select: "选择", confirm: "确认", scope: "切换范围", "to expand": "展开", "to collapse": "收起", "to run bash": "直接跑命令", "to run bash (no context)": "跑命令(不进上下文)", toggle: "切换", "cycle inherit/+/-": "循环 继承/+/-", "for commands": "输入命令", commands: "命令", bash: "命令", close: "关闭", "to close": "关闭", "to attach": "拖入文件即附加", "skip setup": "跳过设置", "switch mode": "切换模式", newline: "换行", "to delete to end": "删到行尾", sort: "排序", named: "只看已命名", rename: "重命名", delete: "删除", "to exit": "退出", "to cycle models": "切换模型", "external editor": "外部编辑器", "clear/exit": "清空/退出" };\nconst u1s1Hint = (d) => U1S1_HINTS[d] ?? d;\nexport function keyHint(keybinding, description) {',
294
+ ],
295
+ { from: 'theme.fg("muted", ` ${description}`)', to: 'theme.fg("muted", ` ${u1s1Hint(description)}`)', expected: 2 },
296
+ ];
297
+ const MODEL_SELECTOR_REPLACEMENTS = [
298
+ // 两处 modelText(有/无两空格缩进)同一规则覆盖;测试的空白折叠匹配不认双空格
299
+ { from: '${item.id}`;', to: '${item.model.name || item.id}`;', expected: 2 },
300
+ ['Model Name: ${selected.model.name}`)', '模型 ID: ${selected.model.id}`)'],
301
+ ['No matching models")', '没有匹配的模型")'],
302
+ ['theme.fg("muted", " · default")', 'theme.fg("muted", " · 默认")'],
303
+ ['const allText = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all");', 'const allText = this.scope === "all" ? theme.fg("accent", "全部") : theme.fg("muted", "全部");'],
304
+ ['const scopedText = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped");', 'const scopedText = this.scope === "scoped" ? theme.fg("accent", "已配置") : theme.fg("muted", "已配置");'],
305
+ ['theme.fg("muted", "Scope: ")', 'theme.fg("muted", "范围: ")'],
306
+ ['theme.fg("muted", " (all/scoped)")', 'theme.fg("muted", " (全部/已配置)")'],
307
+ ['refreshStatusMessage = "Refreshing model catalogs…";', 'refreshStatusMessage = "正在刷新模型列表…";'],
308
+ ['"Model catalogs refreshed."', '"模型列表已刷新。"'],
309
+ ['const hintText = "Only showing models from configured providers. Use /login to add providers.";', 'const hintText = "只显示已配置渠道的模型。";'],
310
+ ];
311
+ const SETTINGS_REPLACEMENTS = [
312
+ ['off: "No reasoning",', 'off: "不推理",'],
313
+ ['minimal: "Very brief reasoning (~1k tokens)",', 'minimal: "极简推理(约 1k token)",'],
314
+ ['low: "Light reasoning (~2k tokens)",', 'low: "轻度推理(约 2k token)",'],
315
+ ['medium: "Moderate reasoning (~8k tokens)",', 'medium: "中等推理(约 8k token)",'],
316
+ ['high: "Deep reasoning (~16k tokens)",', 'high: "深度推理(约 16k token)",'],
317
+ ['xhigh: "Extra-high reasoning (~32k tokens)",', 'xhigh: "超深推理(约 32k token)",'],
318
+ ['max: "Maximum reasoning",', 'max: "最大推理",'],
319
+ ['ask: "Ask",', 'ask: "每次询问",'],
320
+ ['always: "Always trust",', 'always: "始终信任",'],
321
+ ['never: "Never trust",', 'never: "从不信任",'],
322
+ ['new SelectSubmenu("Theme", "Select a theme, or choose Automatic to follow terminal appearance."', 'new SelectSubmenu("主题", "选择一个主题,或选「自动」跟随终端明暗。"'],
323
+ ['label: "Automatic",', 'label: "自动",'],
324
+ ['description: "Use separate themes for light and dark terminal appearance",', 'description: "终端明暗各用一套主题",'],
325
+ ['theme.fg("accent", "Automatic Theme")', 'theme.fg("accent", "自动主题")'],
326
+ ['theme.fg("muted", "Choose themes for terminal light and dark appearance.")', 'theme.fg("muted", "分别为终端的浅色/深色外观选主题。")'],
327
+ ['theme.fg("muted", "Light/dark detection requires terminal support.")', 'theme.fg("muted", "明暗检测需要终端支持。")'],
328
+ ['label: "Light theme",', 'label: "浅色主题",'],
329
+ ['description: "Theme to use in automatic mode when the terminal is light",', 'description: "自动模式下终端为浅色时使用的主题",'],
330
+ ['label: "Dark theme",', 'label: "深色主题",'],
331
+ ['description: "Theme to use in automatic mode when the terminal is dark",', 'description: "自动模式下终端为深色时使用的主题",'],
332
+ ['label: "Apply",', 'label: "应用",'],
333
+ ['description: "Save and go back",', 'description: "保存并返回",'],
334
+ ['label: "Change mode",', 'label: "切换模式",'],
335
+ ['description: "Switch to one theme for light and dark",', 'description: "明暗共用一个主题",'],
336
+ ['label: "Auto-compact",', 'label: "自动压缩上下文",'],
337
+ ['description: "Automatically compact context when it gets too large",', 'description: "上下文过长时自动压缩",'],
338
+ ['label: "Steering mode",', 'label: "插话模式",'],
339
+ ['description: "Enter while streaming queues steering messages. \'one-at-a-time\': deliver one, wait for response. \'all\': deliver all at once.",', 'description: "回复进行中按 Enter 发送插话。one-at-a-time:发一条等回复;all:一次全发。",'],
340
+ ['label: "Follow-up mode",', 'label: "追问模式",'],
341
+ ['description: `${followUpKey} queues follow-up messages until agent stops. \'one-at-a-time\': deliver one, wait for response. \'all\': deliver all at once.`,', 'description: `${followUpKey} 把追问排队到本轮结束再发。one-at-a-time:发一条等回复;all:一次全发。`,'],
342
+ ['label: "Transport",', 'label: "传输方式",'],
343
+ ['description: "Preferred transport for providers that support multiple transports",', 'description: "支持多种传输方式的渠道优先用哪种",'],
344
+ ['label: "HTTP idle timeout",', 'label: "HTTP 空闲超时",'],
345
+ ['description: "Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.",', 'description: "等待响应头或数据块的最长空闲间隔;本地模型停顿超过五分钟可关闭。",'],
346
+ ['label: "Hide thinking",', 'label: "隐藏思考过程",'],
347
+ ['description: "Hide thinking blocks in assistant responses",', 'description: "不显示回复里的思考块",'],
348
+ ['label: "Mermaid diagrams",', 'label: "Mermaid 图表",'],
349
+ ['description: "Render Mermaid code blocks as Unicode diagrams",', 'description: "把 Mermaid 代码块渲染成字符图",'],
350
+ ['label: "Cache miss notices",', 'label: "缓存未命中提示",'],
351
+ ['description: "Show transcript notices for significant prompt-cache misses and compaction costs",', 'description: "提示明显的提示词缓存未命中与压缩开销",'],
352
+ ['label: "Collapse changelog",', 'label: "折叠更新日志",'],
353
+ ['description: "Show condensed changelog after updates",', 'description: "更新后只显示精简的更新日志",'],
354
+ ['label: "Quiet startup",', 'label: "安静启动",'],
355
+ ['description: "Disable verbose printing at startup",', 'description: "启动时不打印冗长信息",'],
356
+ ['label: "Install telemetry",', 'label: "安装统计",'],
357
+ ['description: "Send an anonymous version/update ping after changelog-detected updates",', 'description: "更新后匿名上报一次版本信息",'],
358
+ ['label: "Default project trust",', 'label: "默认项目信任",'],
359
+ ['description: "Fallback behavior when no extension or saved trust decision decides project trust",', 'description: "没有扩展或已保存决定时,项目信任的兜底行为",'],
360
+ ['label: "Double-escape action",', 'label: "双击 Esc 动作",'],
361
+ ['description: "Action when pressing Escape twice with empty editor",', 'description: "输入框为空时连按两次 Esc 的动作",'],
362
+ ['label: "Tree filter mode",', 'label: "会话树筛选",'],
363
+ ['description: "Default filter when opening /tree",', 'description: "打开 /tree 时的默认筛选",'],
364
+ ['label: "Warnings",', 'label: "警告提示",'],
365
+ ['description: "Enable or disable individual warnings",', 'description: "逐项开关各类警告",'],
366
+ ['label: "Default thinking level per model",', 'label: "各模型默认推理强度",'],
367
+ ['description: `Override the default thinking level for specific models. ${cycleThinkingKey} cycles in-session.`,', 'description: `按模型覆盖默认推理强度;会话内用 ${cycleThinkingKey} 切换。`,'],
368
+ ['description: "Select a model to configure",', 'description: "选择要设置的模型",'],
369
+ ['label: "No models available",', 'label: "没有可用模型",'],
370
+ ['description: "Log in to a provider or configure an API key first",', 'description: "请先登录",'],
371
+ ['description: "Select default thinking level for this model",', 'description: "选择这个模型的默认推理强度",'],
372
+ ['label: "(clear override)",', 'label: "(清除覆盖)",'],
373
+ ['description: `Revert to global default (${config.thinkingLevel})`,', 'description: `恢复为全局默认(${config.thinkingLevel})`,'],
374
+ ['label: "TUI mode",', 'label: "界面模式",'],
375
+ ['description: "Interface layout; fullscreen mode is experimental",', 'description: "界面布局;全屏模式为实验功能",'],
376
+ ['label: "Fullscreen exit output",', 'label: "退出全屏时的输出",'],
377
+ ['description: "Print the transcript or only a session resume hint when exiting fullscreen mode",', 'description: "退出全屏时打印完整对话,还是只留一条恢复会话提示",'],
378
+ ['label: "Fullscreen scrollbar",', 'label: "全屏滚动条",'],
379
+ ['description: "Scrollbar behavior in fullscreen mode; has no effect in regular mode",', 'description: "全屏模式下的滚动条行为;普通模式无效",'],
380
+ ['label: "Fullscreen copy on select",', 'label: "全屏选中即复制",'],
381
+ ['description: "Automatically copy selected text in fullscreen mode; disable to copy selections with Ctrl+X",', 'description: "全屏模式下选中文字自动复制;关闭后用 Ctrl+X 复制",'],
382
+ ['label: "Theme",', 'label: "主题",'],
383
+ ['description: "Color theme for the interface",', 'description: "界面配色主题",'],
384
+ ];
385
+
386
+ const TUI_TEXT_TARGETS = [
387
+ [["modes", "interactive", "components", "assistant-message.js"], ASSISTANT_TEXT_REPLACEMENTS, "回复状态文案"],
388
+ [["modes", "interactive", "interactive-mode.js"], WORKING_TEXT_REPLACEMENTS, "工作中提示"],
389
+ [["modes", "interactive", "components", "keybinding-hints.js"], KEY_HINT_REPLACEMENTS, "按键脚注"],
390
+ [["modes", "interactive", "components", "model-selector.js"], MODEL_SELECTOR_REPLACEMENTS, "模型选择器"],
391
+ [["modes", "interactive", "components", "settings-selector.js"], SETTINGS_REPLACEMENTS, "设置菜单"],
392
+ ];
393
+
394
+ function patchTuiText() {
395
+ for (const piDir of findPackageDirs("@earendil-works/pi-coding-agent")) {
396
+ for (const [parts, rules, label] of TUI_TEXT_TARGETS) {
397
+ const target = join(piDir, "dist", ...parts);
398
+ try {
399
+ let text = readFileSync(target, "utf8");
400
+ let applied = 0;
401
+ for (const rule of rules) {
402
+ const { from, to, expected } = Array.isArray(rule) ? { from: rule[0], to: rule[1], expected: 1 } : rule;
403
+ if (text.includes(to)) {
404
+ applied++;
405
+ continue;
406
+ }
407
+ if (text.split(from).length !== expected + 1) continue;
408
+ text = text.split(from).join(to);
409
+ applied++;
410
+ }
411
+ if (applied < rules.length) {
412
+ console.log(
413
+ `[u1s1] 提示: pi 版本可能已更新,${label}汉化补丁只应用了 ${applied}/${rules.length} 处(不影响使用)`,
414
+ );
415
+ }
416
+ writeFileSync(target, text);
417
+ } catch {
418
+ continue;
419
+ }
420
+ }
421
+ }
422
+ }
423
+
274
424
  function patchMainScreenAutowrap() {
275
425
  const tuiDirs = findTuiDirs();
276
426
  if (tuiDirs.length === 0) {
@@ -329,6 +479,7 @@ try {
329
479
  patchSlashCommands();
330
480
  patchExtensionError();
331
481
  patchHotkeys();
482
+ patchTuiText();
332
483
  patchMainScreenAutowrap();
333
484
  } catch (err) {
334
485
  console.log(`[u1s1] 提示: pi 运行时补丁未生效(${err?.message ?? err}),不影响安装`);