@tiens.nguyen/gu-cli 1.0.783 → 1.0.785

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/mcp-config.mjs CHANGED
@@ -18,13 +18,27 @@
18
18
  * tool is read-only (readOnlyHint) is NOT a rule: the MCP spec says clients must treat those hints
19
19
  * as untrusted, and a hostile server would simply claim it.
20
20
  *
21
+ * CLAUDE CODE'S SERVERS (#325). A folder that chose "Claude's skills" (/switch-skill) takes YOUR
22
+ * servers from Claude Code's own file instead of ~/.gonext/mcp.json:
23
+ * ~/.claude.json projects["<this folder>"].mcpServers (Claude's "local" scope — this folder only)
24
+ * ~/.claude.json mcpServers (Claude's "user" scope — every folder)
25
+ * The project's .mcp.json is the same file either way. Yours still win over the project's. gu's own
26
+ * bookkeeping — disabled, the allow/deny rules, which project servers you approved — stays in
27
+ * ~/.gonext/mcp.json whichever source is chosen: Claude Code's file has no place for it, and gu
28
+ * reads Claude's setup without Claude ever having to read gu's.
29
+ *
30
+ * ~/.claude.json IS CLAUDE CODE'S WHOLE STATE FILE, not a config file of ours. It is only written
31
+ * when you add or remove a server yourself, and then only its mcpServers entry changes: re-read,
32
+ * change one key, write back atomically with the file's own permissions.
33
+ *
21
34
  * ONE WRITER SHAPE. Both `gu mcp` and `/mcp` go through updateUserConfig, which re-reads the file,
22
35
  * changes one thing and writes it back atomically — keys it does not know are kept. Rebuilding the
23
36
  * object from what one side knows is how a settings screen once deleted the other side's entries.
24
37
  */
25
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
38
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
26
39
  import { homedir } from "node:os";
27
40
  import { dirname, join, resolve } from "node:path";
41
+ import { resolveSkillSource } from "./skill-source.mjs";
28
42
 
29
43
  export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
30
44
 
@@ -34,6 +48,13 @@ export function userConfigPath(home = homedir()) {
34
48
  export function projectConfigPath(cwd) {
35
49
  return join(cwd, ".mcp.json");
36
50
  }
51
+ /** Claude Code's state file — where its user- and local-scope MCP servers are (#325). */
52
+ export function claudeConfigPath(home = homedir()) {
53
+ return join(home, ".claude.json");
54
+ }
55
+ /** The file YOUR servers are in, for the source in use. */
56
+ export const userServersPath = (home = homedir(), source = "gu") =>
57
+ source === "claude" ? claudeConfigPath(home) : userConfigPath(home);
37
58
 
38
59
  function readJson(file) {
39
60
  let text;
@@ -67,6 +88,39 @@ export function readUserConfig(home = homedir()) {
67
88
 
68
89
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
69
90
 
91
+ /** Claude Code's servers for this folder → { local, user, error? }. A missing file is no servers. */
92
+ export function readClaudeServers({ cwd, home = homedir() } = {}) {
93
+ const { data, error } = readJson(claudeConfigPath(home));
94
+ const proj = cwd && isObj(data?.projects) ? data.projects[resolve(cwd)] : null;
95
+ return {
96
+ error,
97
+ local: isObj(proj?.mcpServers) ? proj.mcpServers : {},
98
+ user: isObj(data?.mcpServers) ? data.mcpServers : {},
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Change ~/.claude.json: re-read, edit in place, write back atomically, permissions kept. Refused
104
+ * when the file is not valid JSON — it is Claude Code's, and not ours to "repair".
105
+ */
106
+ export function updateClaudeConfig(mutate, home = homedir()) {
107
+ const file = claudeConfigPath(home);
108
+ const { data, error } = readJson(file);
109
+ if (error) throw new Error(`${file} is ${error} — fix it first (it is Claude Code's own file)`);
110
+ const raw = data ?? {};
111
+ mutate(raw);
112
+ let mode = 0o600;
113
+ try {
114
+ mode = statSync(file).mode & 0o777;
115
+ } catch {
116
+ /* a new file: private, as Claude Code makes it */
117
+ }
118
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
119
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode });
120
+ renameSync(tmp, file);
121
+ return raw;
122
+ }
123
+
70
124
  /** Write ~/.gonext/mcp.json atomically: a crash mid-write leaves the old file, never half of one. */
71
125
  function writeUserRaw(raw, home) {
72
126
  const file = userConfigPath(home);
@@ -103,12 +157,17 @@ export function normalizeServer(entry) {
103
157
 
104
158
  /**
105
159
  * Every server this folder knows of, with its state.
106
- * → { servers: [{ name, scope, type, command, args, env, disabled, approved, runnable, error? }], warnings }
160
+ * `source` = whose servers ("gu" | "claude"); left out, it is the folder's own choice.
161
+ * scope: "user" (yours, every folder), "local" (Claude's: yours, this folder only), "project".
162
+ * → { servers: [{ name, scope, type, command, args, env, disabled, approved, runnable, error? }], warnings, source }
107
163
  */
108
- export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
164
+ export function listServers({ cwd = process.cwd(), home = homedir(), source } = {}) {
165
+ const src = resolveSkillSource({ source, cwd, home });
109
166
  const user = readUserConfig(home);
110
167
  const warnings = [];
111
- if (user.error) warnings.push(`${userConfigPath(home)} is ${user.error} — its servers are skipped`);
168
+ if (user.error) {
169
+ warnings.push(`${userConfigPath(home)} is ${user.error} — ${src === "claude" ? "its rules and approvals are skipped" : "its servers are skipped"}`);
170
+ }
112
171
  const servers = [];
113
172
  const seen = new Set();
114
173
  const add = (name, entry, scope, approved) => {
@@ -129,7 +188,14 @@ export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
129
188
  runnable: !n.error && !disabled && approved,
130
189
  });
131
190
  };
132
- for (const [name, entry] of Object.entries(user.mcpServers)) add(name, entry, "user", true);
191
+ if (src === "claude") {
192
+ const claude = readClaudeServers({ cwd, home });
193
+ if (claude.error) warnings.push(`${claudeConfigPath(home)} is ${claude.error} — its servers are skipped`);
194
+ for (const [name, entry] of Object.entries(claude.local)) add(name, entry, "local", true);
195
+ for (const [name, entry] of Object.entries(claude.user)) add(name, entry, "user", true);
196
+ } else {
197
+ for (const [name, entry] of Object.entries(user.mcpServers)) add(name, entry, "user", true);
198
+ }
133
199
  if (cwd) {
134
200
  const proj = readJson(projectConfigPath(cwd));
135
201
  if (proj.error) warnings.push(`${projectConfigPath(cwd)} is ${proj.error} — its servers are skipped`);
@@ -138,12 +204,12 @@ export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
138
204
  add(name, entry, "project", approvedHere.includes(name));
139
205
  }
140
206
  }
141
- return { servers, warnings };
207
+ return { servers, warnings, source: src };
142
208
  }
143
209
 
144
210
  /** What the worker hands the agent for one turn: the servers it may start, and the rules. */
145
- export function mcpForTurn({ cwd, home = homedir() } = {}) {
146
- const { servers } = listServers({ cwd, home });
211
+ export function mcpForTurn({ cwd, home = homedir(), source } = {}) {
212
+ const { servers } = listServers({ cwd, home, source });
147
213
  const user = readUserConfig(home);
148
214
  return {
149
215
  servers: servers
@@ -177,7 +243,7 @@ export function decide({ allow = [], deny = [] }, server, tool) {
177
243
 
178
244
  /* ---------------------------------------------------------------- changes -------------- */
179
245
 
180
- export function addServer({ name, command, args = [], env = {}, scope = "user", cwd, home = homedir() }) {
246
+ export function addServer({ name, command, args = [], env = {}, scope = "user", cwd, home = homedir(), source }) {
181
247
  if (!SERVER_NAME_RE.test(name ?? "")) throw new Error(`"${name}" is not a valid name — use letters, digits, _ or -`);
182
248
  if (!String(command ?? "").trim()) throw new Error("no command given");
183
249
  const entry = { type: "stdio", command: String(command), args: args.map(String), env };
@@ -196,6 +262,15 @@ export function addServer({ name, command, args = [], env = {}, scope = "user",
196
262
  approveProjectServer({ name, cwd, home });
197
263
  return { file };
198
264
  }
265
+ if (resolveSkillSource({ source, cwd, home }) === "claude") {
266
+ // Claude Code's user scope. Its entry, its file; gu's file only forgets a stale "disabled".
267
+ updateClaudeConfig((raw) => {
268
+ raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
269
+ raw.mcpServers[name] = entry;
270
+ }, home);
271
+ if (readUserConfig(home).disabled.includes(name)) setDisabled({ name, disabled: false, home });
272
+ return { file: claudeConfigPath(home) };
273
+ }
199
274
  updateUserConfig((raw) => {
200
275
  raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
201
276
  raw.mcpServers[name] = entry;
@@ -205,10 +280,23 @@ export function addServer({ name, command, args = [], env = {}, scope = "user",
205
280
  }
206
281
 
207
282
  /** Remove from YOUR file. A project's server cannot be removed from here — only not approved. */
208
- export function removeServer({ name, home = homedir() }) {
283
+ export function removeServer({ name, home = homedir(), cwd, source }) {
209
284
  let found = false;
285
+ const claude = resolveSkillSource({ source, cwd, home }) === "claude";
286
+ if (claude) {
287
+ // From Claude Code's file: this folder's own entry and the every-folder one.
288
+ const has = readClaudeServers({ cwd, home });
289
+ if (name in has.local || name in has.user) {
290
+ updateClaudeConfig((raw) => {
291
+ if (isObj(raw.mcpServers) && name in raw.mcpServers) delete raw.mcpServers[name];
292
+ const proj = cwd && isObj(raw.projects) ? raw.projects[resolve(cwd)] : null;
293
+ if (isObj(proj?.mcpServers) && name in proj.mcpServers) delete proj.mcpServers[name];
294
+ }, home);
295
+ found = true;
296
+ }
297
+ }
210
298
  updateUserConfig((raw) => {
211
- if (isObj(raw.mcpServers) && name in raw.mcpServers) {
299
+ if (!claude && isObj(raw.mcpServers) && name in raw.mcpServers) {
212
300
  delete raw.mcpServers[name];
213
301
  found = true;
214
302
  }