paintress 0.2.3 → 0.3.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.
package/README.md CHANGED
@@ -84,8 +84,10 @@ Canvas navigation: wheel zooms at the pointer, space / middle button / empty are
84
84
  | Claude Desktop | `~/.config/Claude/claude_desktop_config.json` | `mcpServers` |
85
85
  | Cursor | `~/.cursor/mcp.json` | `mcpServers` |
86
86
  | VS Code | `~/.config/Code/User/mcp.json` | `servers` (with `"type": "stdio"`) |
87
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
87
88
  | Codex | `~/.codex/config.toml` | `[mcp_servers.paintress]` |
88
89
  | pi | `~/.pi/agent/mcp.json` | `mcpServers` |
90
+ | OpenCode | `~/.config/opencode/opencode.json` | `mcp.servers` (current) or `mcp` (older; `command` is an array) |
89
91
 
90
92
  ```bash
91
93
  paintress mcp-config cursor # print the exact snippet instead of editing files
@@ -126,9 +128,15 @@ You are an agent asked to install paintress on this machine. Work through the st
126
128
  Rules:
127
129
 
128
130
  - **Any hit means reuse.** Do not install again; go to step 2.
129
- - Report the resolved path and version (`paintress ping` prints the daemon version when the viewer runs; `npm ls -g --depth=0 paintress` prints the package version).
131
+ - Report the resolved path and version: `paintress --version` prints the package version (or `npm ls -g --depth=0 paintress`).
130
132
  - Only when every check is empty do you install (step 1).
131
133
 
134
+ If the install succeeded but `command -v paintress` still finds nothing (some version managers need a new shell), call it by full path — do not install a second copy:
135
+
136
+ ```bash
137
+ "$(npm prefix -g)/bin/paintress" doctor
138
+ ```
139
+
132
140
  ### 1. Install (only if step 0 found nothing)
133
141
 
134
142
  ```bash
@@ -152,7 +160,9 @@ paintress install --dry-run # preview the writes
152
160
  paintress install <client-id> # write only the client you are running in
153
161
  ```
154
162
 
155
- Client ids: `claude`, `cursor`, `vscode`, `codex`, `pi`. Use `paintress install` without arguments to cover every detected client.
163
+ Client ids: `claude`, `cursor`, `vscode`, `windsurf`, `codex`, `pi`, `opencode`. Use `paintress install` without arguments to cover every detected client.
164
+
165
+ OpenCode has two config layouts in the wild. `install` follows whatever the existing file already uses and falls back to the current `mcp.servers` shape for a new file; force it with `--opencode-layout legacy` or `--opencode-layout v2`.
156
166
 
157
167
  What the write does:
158
168
 
@@ -162,14 +172,23 @@ What the write does:
162
172
  - is idempotent: an identical entry reports `noop` and nothing is written;
163
173
  - uses the bare command `paintress` when it resolves on PATH, otherwise the absolute path of the binary.
164
174
 
165
- If the agent you run in is not in that list, print the closest snippet and adapt it:
175
+ ### If your client is not in that list
176
+
177
+ The entry is small and nearly the same everywhere, so adapt it by hand:
178
+
179
+ 1. Find where the client keeps its MCP servers — its own docs, an `/mcp` command, or a "configure MCP servers" menu item.
180
+ 2. Match the shape. Most clients use a `mcpServers` object holding `command` + `args`; VS Code uses `servers` with `"type": "stdio"`; OpenCode puts a single `command` **array** under `mcp.servers` (older builds: under `mcp`) with `"type": "local"`; Codex uses a TOML table.
181
+ 3. Print the closest snippet and edit it:
166
182
 
167
183
  ```bash
168
- paintress mcp-config cursor # JSON with mcpServers
169
- paintress mcp-config vscode # JSON with servers + type: stdio
170
- paintress mcp-config codex # TOML table
184
+ paintress mcp-config cursor # JSON with mcpServers
185
+ paintress mcp-config vscode # JSON with servers + type: stdio
186
+ paintress mcp-config opencode # JSON with mcp.servers + command array
187
+ paintress mcp-config codex # TOML table
171
188
  ```
172
189
 
190
+ To make a client first-class (so `doctor` and `install` cover it), add one spec to `src/integrations/clients.ts` — `id`, `label`, `format`, `rootPath`, optional `typeValue` / `commandForm` / `extraFields`, and `configPath(home, platform, env)` — plus one test in `test/integrations.spec.ts`. No other file needs to change.
191
+
173
192
  ### 3. Install the skill (recommended)
174
193
 
175
194
  ```bash
package/README.zh-CN.md CHANGED
@@ -84,8 +84,10 @@ paintress watch demo ./demo
84
84
  | Claude Desktop | `~/.config/Claude/claude_desktop_config.json` | `mcpServers` |
85
85
  | Cursor | `~/.cursor/mcp.json` | `mcpServers` |
86
86
  | VS Code | `~/.config/Code/User/mcp.json` | `servers`(条目需 `"type": "stdio"`) |
87
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
87
88
  | Codex | `~/.codex/config.toml` | `[mcp_servers.paintress]` |
88
89
  | pi | `~/.pi/agent/mcp.json` | `mcpServers` |
90
+ | OpenCode | `~/.config/opencode/opencode.json` | `mcp.servers`(当前版)或 `mcp`(旧版;`command` 是数组) |
89
91
 
90
92
  ```bash
91
93
  paintress mcp-config cursor # 只打印片段,不改文件
@@ -126,9 +128,15 @@ MIT
126
128
  规则:
127
129
 
128
130
  - **命中任意一项就复用**,不要重装,直接进入第 2 步。
129
- - 向用户报告解析到的路径与版本(查看器运行时 `paintress ping` 会打印版本;包版本用 `npm ls -g --depth=0 paintress`)。
131
+ - 向用户报告解析到的路径与版本:`paintress --version` 打印包版本(或用 `npm ls -g --depth=0 paintress`)。
130
132
  - 只有在全部检查都为空时,才执行第 1 步。
131
133
 
134
+ 如果安装成功但 `command -v paintress` 仍然找不到(某些版本管理器需要新开 shell),用完整路径调用,**不要因此再装一份**:
135
+
136
+ ```bash
137
+ "$(npm prefix -g)/bin/paintress" doctor
138
+ ```
139
+
132
140
  ### 1. 安装(仅在检测全空时)
133
141
 
134
142
  ```bash
@@ -152,7 +160,9 @@ paintress install --dry-run # 预览将要写入的内容
152
160
  paintress install <client-id> # 只写当前所在的客户端
153
161
  ```
154
162
 
155
- 客户端 id:`claude`、`cursor`、`vscode`、`codex`、`pi`。不带参数执行 `paintress install` 会覆盖所有检测到的客户端。
163
+ 客户端 id:`claude`、`cursor`、`vscode`、`windsurf`、`codex`、`pi`、`opencode`。不带参数执行 `paintress install` 会覆盖所有检测到的客户端。
164
+
165
+ OpenCode 现在有两套配置布局:`install` 会跟随文件里既有的布局,新文件按官方当前的 `mcp.servers`;需要强制时用 `--opencode-layout legacy` 或 `--opencode-layout v2`。
156
166
 
157
167
  写入行为:
158
168
 
@@ -162,14 +172,23 @@ paintress install <client-id> # 只写当前所在的客户端
162
172
  - 幂等:条目已一致时返回 `noop`,不写磁盘;
163
173
  - 当 `paintress` 能在 PATH 上解析时写裸命令,否则写可执行文件的绝对路径。
164
174
 
165
- 如果当前 agent 不在列表里,打印最接近的片段再自行适配:
175
+ ### 如果当前 agent 不在列表里
176
+
177
+ 条目很小,各家格式几乎一致,直接照抄改写即可:
178
+
179
+ 1. 先找到该客户端放 MCP server 的位置——查它自己的文档、`/mcp` 命令,或「配置 MCP 服务器」菜单项。
180
+ 2. 对照格式:多数是 `mcpServers` 对象,里面 `command` + `args`;VS Code 是 `servers` 加 `"type": "stdio"`;OpenCode 的 `command` 是**数组**,放在 `mcp.servers` 下(旧版放 `mcp` 下)并带 `"type": "local"`;Codex 是 TOML 表。
181
+ 3. 打印最接近的片段再改:
166
182
 
167
183
  ```bash
168
- paintress mcp-config cursor # JSON,顶层 mcpServers
169
- paintress mcp-config vscode # JSON,顶层 servers + type: stdio
170
- paintress mcp-config codex # TOML 表
184
+ paintress mcp-config cursor # JSON,顶层 mcpServers
185
+ paintress mcp-config vscode # JSON,顶层 servers + type: stdio
186
+ paintress mcp-config opencode # JSON,mcp.servers + command 数组
187
+ paintress mcp-config codex # TOML 表
171
188
  ```
172
189
 
190
+ 想让某个客户端成为内置支持(`doctor`、`install` 都能覆盖),只需在 `src/integrations/clients.ts` 里加一条配置:`id`、`label`、`format`、`rootPath`,以及可选的 `typeValue` / `commandForm` / `extraFields` 和 `configPath(home, platform, env)`,再在 `test/integrations.spec.ts` 加一个测试。其他文件都不用动。
191
+
173
192
  ### 3. 安装 skill(建议)
174
193
 
175
194
  ```bash
package/dist/cli.js CHANGED
@@ -859,8 +859,7 @@ const CLIENTS = [
859
859
  id: "claude",
860
860
  label: "Claude Desktop",
861
861
  format: "json",
862
- rootKey: "mcpServers",
863
- needsType: false,
862
+ rootPath: ["mcpServers"],
864
863
  configPath: (home, platform, env) => {
865
864
  if (platform === "darwin") return `${posixHome(home)}/Library/Application Support/Claude/claude_desktop_config.json`;
866
865
  if (platform === "win32") return `${env["APPDATA"] ?? posixHome(home)}/Claude/claude_desktop_config.json`;
@@ -871,37 +870,54 @@ const CLIENTS = [
871
870
  id: "cursor",
872
871
  label: "Cursor",
873
872
  format: "json",
874
- rootKey: "mcpServers",
875
- needsType: false,
873
+ rootPath: ["mcpServers"],
876
874
  configPath: (home) => `${posixHome(home)}/.cursor/mcp.json`
877
875
  },
878
876
  {
879
877
  id: "vscode",
880
878
  label: "VS Code",
881
879
  format: "json",
882
- rootKey: "servers",
883
- needsType: true,
880
+ rootPath: ["servers"],
881
+ typeValue: "stdio",
884
882
  configPath: (home, platform, env) => {
885
883
  if (platform === "darwin") return `${posixHome(home)}/Library/Application Support/Code/User/mcp.json`;
886
884
  if (platform === "win32") return `${env["APPDATA"] ?? posixHome(home)}/Code/User/mcp.json`;
887
885
  return `${posixHome(home)}/.config/Code/User/mcp.json`;
888
886
  }
889
887
  },
888
+ {
889
+ id: "windsurf",
890
+ label: "Windsurf",
891
+ format: "json",
892
+ rootPath: ["mcpServers"],
893
+ configPath: (home, platform, env) => {
894
+ return `${platform === "win32" ? env["USERPROFILE"] ?? posixHome(home) : posixHome(home)}/.codeium/windsurf/mcp_config.json`;
895
+ }
896
+ },
890
897
  {
891
898
  id: "codex",
892
899
  label: "Codex",
893
900
  format: "toml",
894
- rootKey: "mcp_servers",
895
- needsType: false,
901
+ rootPath: ["mcp_servers"],
896
902
  configPath: (home) => `${posixHome(home)}/.codex/config.toml`
897
903
  },
898
904
  {
899
905
  id: "pi",
900
906
  label: "pi",
901
907
  format: "json",
902
- rootKey: "mcpServers",
903
- needsType: false,
908
+ rootPath: ["mcpServers"],
904
909
  configPath: (home) => `${posixHome(home)}/.pi/agent/mcp.json`
910
+ },
911
+ {
912
+ id: "opencode",
913
+ label: "OpenCode",
914
+ format: "json",
915
+ rootPath: ["mcp", "servers"],
916
+ typeValue: "local",
917
+ commandForm: "command-array",
918
+ configPath: (home, _platform, env) => {
919
+ return `${env["XDG_CONFIG_HOME"] ?? `${posixHome(home)}/.config`}/opencode/opencode.json`;
920
+ }
905
921
  }
906
922
  ];
907
923
  function findClient(id) {
@@ -953,33 +969,84 @@ function resolveLaunchCommand(opts) {
953
969
  args: ["mcp"]
954
970
  };
955
971
  }
956
- /** 单个客户端的配置条目 */
957
- function buildEntry(spec, launch) {
958
- const base = {
959
- command: launch.command,
960
- args: launch.args
961
- };
962
- return spec.needsType ? {
963
- type: "stdio",
964
- ...base
965
- } : base;
972
+ /** 单个客户端的配置条目;OpenCode 旧布局还需要 enabled: true */
973
+ function buildEntry(spec, launch, layout = "auto") {
974
+ const entry = {};
975
+ if (spec.typeValue !== void 0) entry["type"] = spec.typeValue;
976
+ if (spec.commandForm === "command-array") entry["command"] = [launch.command, ...launch.args];
977
+ else {
978
+ entry["command"] = launch.command;
979
+ entry["args"] = launch.args;
980
+ }
981
+ if (spec.extraFields !== void 0) Object.assign(entry, spec.extraFields);
982
+ if (spec.id === "opencode" && layout === "legacy") entry["enabled"] = true;
983
+ return entry;
984
+ }
985
+ function isRecord$1(value) {
986
+ return typeof value === "object" && value !== null && !Array.isArray(value);
987
+ }
988
+ /**
989
+ * 解析 server 表在配置里的路径。
990
+ *
991
+ * OpenCode 有两套布局:V2 把 server 放在 `mcp.servers` 下,旧布局直接放在 `mcp` 下。
992
+ * 已有文件按实际内容判断(避免把条目写到当前版本不读的位置),新文件用官方当前的 V2。
993
+ */
994
+ function resolveRootPath(spec, existingRoot, layout = "auto") {
995
+ if (spec.id !== "opencode") return spec.rootPath;
996
+ if (layout === "legacy") return ["mcp"];
997
+ if (layout === "v2") return ["mcp", "servers"];
998
+ const mcp = existingRoot?.["mcp"];
999
+ if (isRecord$1(mcp)) {
1000
+ if (isRecord$1(mcp["servers"])) return ["mcp", "servers"];
1001
+ if (Object.values(mcp).some((v) => isRecord$1(v) && (typeof v["type"] === "string" || "command" in v))) return ["mcp"];
1002
+ }
1003
+ return ["mcp", "servers"];
966
1004
  }
967
1005
  /** Codex 用的 TOML 片段(改 TOML 风险高,只输出不写入) */
968
1006
  function tomlSnippet(launch, name = "paintress") {
969
1007
  const args = launch.args.map((a) => JSON.stringify(a)).join(", ");
970
1008
  return `[mcp_servers.${name}]\ncommand = ${JSON.stringify(launch.command)}\nargs = [${args}]\n`;
971
1009
  }
972
- /** JSON 客户端用的片段(含顶层键) */
1010
+ /** JSON 客户端用的片段(含嵌套路径,OpenCode 按默认布局) */
973
1011
  function jsonSnippet(spec, launch, name = "paintress") {
974
- return JSON.stringify({ [spec.rootKey]: { [name]: buildEntry(spec, launch) } }, null, 2);
1012
+ const path = resolveRootPath(spec, null);
1013
+ const entry = buildEntry(spec, launch, path.length === 1 ? "legacy" : "v2");
1014
+ let cursor = { [name]: entry };
1015
+ for (const key of [...path].reverse()) cursor = { [key]: cursor };
1016
+ return JSON.stringify(cursor, null, 2);
975
1017
  }
976
1018
  // src/integrations/install.ts
977
1019
  /**
978
1020
  * 配置写入:解析 → 合并 → 备份 → 原子落盘。
979
1021
  * 只动 `rootKey` 下名为 `paintress` 的那一条,其余内容与顺序保持不变(JSON 重新序列化)。
980
1022
  */
1023
+ function isRecord(value) {
1024
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1025
+ }
1026
+ /** 按路径取出 server 表(不存在或类型不对时返回空对象) */
1027
+ function readTable(root, path) {
1028
+ let cursor = root;
1029
+ for (const key of path) {
1030
+ if (!isRecord(cursor)) return {};
1031
+ cursor = cursor[key];
1032
+ }
1033
+ return isRecord(cursor) ? { ...cursor } : {};
1034
+ }
1035
+ /** 按路径写回 server 表,沿途缺失的层级补建,其余键原样保留 */
1036
+ function writeTable(root, path, table) {
1037
+ let cursor = root;
1038
+ path.forEach((key, index) => {
1039
+ if (index === path.length - 1) {
1040
+ cursor[key] = table;
1041
+ return;
1042
+ }
1043
+ const next = isRecord(cursor[key]) ? { ...cursor[key] } : {};
1044
+ cursor[key] = next;
1045
+ cursor = next;
1046
+ });
1047
+ }
981
1048
  /** 纯函数:把 paintress 条目并入现有配置文本 */
982
- function planJsonMerge(spec, path, existing, launch) {
1049
+ function planJsonMerge(spec, path, existing, launch, options = {}) {
983
1050
  const base = {
984
1051
  spec,
985
1052
  path,
@@ -1024,9 +1091,10 @@ function planJsonMerge(spec, path, existing, launch) {
1024
1091
  detail: "配置文件不是合法 JSON,未改动"
1025
1092
  };
1026
1093
  }
1027
- const servers = root[spec.rootKey];
1028
- const table = typeof servers === "object" && servers !== null && !Array.isArray(servers) ? { ...servers } : {};
1029
- const entry = buildEntry(spec, launch);
1094
+ const rootPath = resolveRootPath(spec, root, options.opencodeLayout ?? "auto");
1095
+ const layout = spec.id === "opencode" && rootPath.length === 1 ? "legacy" : "v2";
1096
+ const table = readTable(root, rootPath);
1097
+ const entry = buildEntry(spec, launch, layout);
1030
1098
  const current = table["paintress"];
1031
1099
  if (JSON.stringify(current) === JSON.stringify(entry)) return {
1032
1100
  ...base,
@@ -1036,7 +1104,7 @@ function planJsonMerge(spec, path, existing, launch) {
1036
1104
  };
1037
1105
  const existed = current !== void 0;
1038
1106
  table["paintress"] = entry;
1039
- root[spec.rootKey] = table;
1107
+ writeTable(root, rootPath, table);
1040
1108
  return {
1041
1109
  ...base,
1042
1110
  status: existed ? "update" : existing === null ? "create" : "update",
@@ -1076,7 +1144,8 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
1076
1144
  "port",
1077
1145
  "idle",
1078
1146
  "daemon",
1079
- "dry-run"
1147
+ "dry-run",
1148
+ "opencode-layout"
1080
1149
  ]);
1081
1150
  const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
1082
1151
  "regex",
@@ -1144,9 +1213,13 @@ async function call(tool, args) {
1144
1213
  }
1145
1214
  /** 入口:返回退出码(可单测) */
1146
1215
  async function main(argv) {
1216
+ if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
1217
+ console.log(`paintress ${VERSION}`);
1218
+ return 0;
1219
+ }
1147
1220
  const parsed = parseArgv(argv);
1148
1221
  if (!parsed) {
1149
- console.error("用法:paintress <mcp|web|ping|status|watch|unwatch|attach|stop|info|query|get|update|insert|delete|move|batch|render|shot|validate|install|doctor|mcp-config> ...");
1222
+ console.error("用法:paintress <mcp|web|ping|status|watch|unwatch|attach|stop|info|query|get|update|insert|delete|move|batch|render|shot|validate|install|doctor|mcp-config> ...(版本:paintress --version)");
1150
1223
  return 2;
1151
1224
  }
1152
1225
  const { cmd, positionals, flags } = parsed;
@@ -1230,13 +1303,15 @@ async function main(argv) {
1230
1303
  const unknown = ids.filter((id) => findClient(id) === void 0);
1231
1304
  if (unknown.length > 0) throw new Error(`未知客户端:${unknown.join(", ")}(可选:${CLIENTS.map((c) => c.id).join(" / ")})`);
1232
1305
  const dryRun = cmd === "doctor" || flags["dry-run"] === true;
1306
+ const layoutFlag = flagStr(flags, "opencode-layout") ?? "auto";
1307
+ if (layoutFlag !== "auto" && layoutFlag !== "legacy" && layoutFlag !== "v2") throw new Error(`--opencode-layout 只接受 auto / legacy / v2,收到:${layoutFlag}`);
1233
1308
  const results = [];
1234
1309
  for (const id of ids) {
1235
1310
  const spec = findClient(id);
1236
1311
  if (spec === void 0) continue;
1237
1312
  const path = spec.configPath(home, process.platform, process.env);
1238
1313
  const existing = readConfig(path);
1239
- const plan = planJsonMerge(spec, path, existing, launch);
1314
+ const plan = planJsonMerge(spec, path, existing, launch, { opencodeLayout: layoutFlag });
1240
1315
  if (cmd === "doctor") {
1241
1316
  results.push({
1242
1317
  client: spec.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paintress",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Design-document operation layer for AI agents: read, query, edit, render",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  }
23
23
  },
24
24
  "bin": {
25
- "paintress": "./dist/cli.js"
25
+ "paintress": "dist/cli.js"
26
26
  },
27
27
  "files": [
28
28
  "dist",