@tiens.nguyen/gu-cli 1.0.784 → 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/gu_skills.py CHANGED
@@ -21,8 +21,11 @@ def clean_list(skills) -> list[dict]:
21
21
  out = []
22
22
  for s in skills or []:
23
23
  if isinstance(s, dict) and s.get("name") and s.get("path"):
24
- out.append({"name": str(s["name"]), "description": str(s.get("description") or ""),
25
- "path": str(s["path"]), "dir": str(s.get("dir") or os.path.dirname(str(s["path"])))})
24
+ item = {"name": str(s["name"]), "description": str(s.get("description") or ""),
25
+ "path": str(s["path"]), "dir": str(s.get("dir") or os.path.dirname(str(s["path"])))}
26
+ if s.get("source") == "claude":
27
+ item["source"] = "claude" # written for Claude Code — see CLAUDE_NOTE
28
+ out.append(item)
26
29
  return out
27
30
 
28
31
 
@@ -53,10 +56,67 @@ def find(skills: list[dict], name: str) -> dict | None:
53
56
  return next((s for s in skills if s["name"] == n), None)
54
57
 
55
58
 
56
- def render(skill: dict, body: str) -> str:
59
+ # A SKILL WRITTEN FOR CLAUDE CODE (task #325) names Claude Code's tools. The instructions are still
60
+ # the instructions; only the tool names differ, and the model is told which of its own to use.
61
+ CLAUDE_NOTE = (
62
+ "This skill was written for Claude Code, so it names Claude Code's tools. Use yours instead: "
63
+ "Bash → run_command · Read → read_file_lines / read_text_file · Edit → edit_file / edit_lines · "
64
+ "Write → create_file · Grep → grep_repo · Glob / LS → list_dir. Tools named mcp__<server>__<tool> "
65
+ "are the same here. Anything else it names that you do not have (Task, TodoWrite, WebFetch, "
66
+ "a subagent…): do that part yourself with the tools you do have, or say that you cannot. "
67
+ "A line written as !`command` was meant to be run before you read this — it was NOT run; "
68
+ "run it yourself only if the task needs its output."
69
+ )
70
+
71
+ _ARG_INDEX_RE = re.compile(r"\$ARGUMENTS\[(\d+)\]|\$(\d+)\b")
72
+
73
+
74
+ def split_args(request: str) -> list[str]:
75
+ """`"hello world" second` → ["hello world", "second"] — Claude Code's quoting."""
76
+ out, cur, quote, any_ = [], "", "", False
77
+ for ch in request or "":
78
+ if quote:
79
+ if ch == quote:
80
+ quote = ""
81
+ else:
82
+ cur += ch
83
+ elif ch in "\"'":
84
+ quote, any_ = ch, True
85
+ elif ch.isspace():
86
+ if cur or any_:
87
+ out.append(cur)
88
+ cur, any_ = "", False
89
+ else:
90
+ cur += ch
91
+ if cur or any_:
92
+ out.append(cur)
93
+ return out
94
+
95
+
96
+ def substitute(skill: dict, body: str, request: str | None) -> str:
97
+ """Claude Code's placeholders in a skill body: $ARGUMENTS, $ARGUMENTS[N] / $N, and the skill's
98
+ own folder. `request` is None when the agent picked the skill itself — there are no arguments
99
+ then, and the placeholders are left as written rather than emptied."""
100
+ out = body.replace("${CLAUDE_SKILL_DIR}", skill.get("dir", ""))
101
+ if request is None:
102
+ return out
103
+ args = split_args(request)
104
+
105
+ def one(m):
106
+ i = int(m.group(1) if m.group(1) is not None else m.group(2))
107
+ return args[i] if i < len(args) else ""
108
+
109
+ return _ARG_INDEX_RE.sub(one, out).replace("$ARGUMENTS", request)
110
+
111
+
112
+ def render(skill: dict, body: str, request: str | None = None) -> str:
113
+ claude = skill.get("source") == "claude"
114
+ if claude:
115
+ body = substitute(skill, body, request)
57
116
  return (f'SKILL "{skill["name"]}" — follow these instructions for this task. Files the skill '
58
117
  f'mentions by relative path are in {skill["dir"]}.\n'
59
- f"<skill>\n{body}\n</skill>")
118
+ + (CLAUDE_NOTE + "\n" if claude else "")
119
+ + f"<skill>\n{body}\n</skill>")
60
120
 
61
121
 
62
122
  def use_skill_text(skills: list[dict], name: str) -> str:
package/hooks-command.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * approve run this project's hooks (as they are now — an edit needs approving again)
6
6
  * unapprove stop running them
7
7
  */
8
- import { HOOK_EVENTS, describeHook, readHooks, approveProjectHooks, userSettingsPath } from "./hooks-config.mjs";
8
+ import { HOOK_EVENTS, GU_TO_CLAUDE_EVENT, describeHook, readHooks, approveProjectHooks } from "./hooks-config.mjs";
9
9
 
10
10
  export const HOOKS_USAGE = "gu hooks [list|approve|unapprove]";
11
11
 
@@ -13,7 +13,9 @@ function printGroups(hooks, print, indent = " ") {
13
13
  for (const ev of HOOK_EVENTS) {
14
14
  for (const g of hooks[ev] ?? []) {
15
15
  const on = g.matcher ? ` (tools: ${g.matcher})` : "";
16
- for (const h of g.hooks) print(`${indent}${ev.padEnd(11)} ${describeHook(h)}${on}`);
16
+ // A Claude Code hook is listed under the name it has in Claude's file.
17
+ const name = g.dialect === "claude" ? GU_TO_CLAUDE_EVENT[ev] : ev;
18
+ for (const h of g.hooks) print(`${indent}${name.padEnd(g.dialect === "claude" ? 16 : 11)} ${describeHook(h)}${on}`);
17
19
  }
18
20
  }
19
21
  }
@@ -24,33 +26,36 @@ export async function runHooksCommand(words, { cwd = process.cwd(), print = cons
24
26
  if (sub === "list" || sub === "ls") {
25
27
  const r = readHooks({ cwd });
26
28
  for (const w of r.warnings) print(w, "warn");
29
+ const claude = r.source === "claude";
27
30
  const nUser = Object.keys(r.user).length;
28
31
  const nProj = Object.keys(r.project).length;
32
+ if (claude) print("Claude Code's hooks (/switch-skill) — gu runs them with Claude's event and tool names.", "dim");
29
33
  if (!nUser && !nProj) {
30
- print("No hooks.");
31
- print(`Add them under "hooks" in ${userSettingsPath()} — events: ${HOOK_EVENTS.join(", ")}.`, "dim");
32
- return 0;
34
+ print(claude ? `No hooks gu can run in ${r.userFile} or ${r.projectFile}.` : "No hooks.");
35
+ if (!claude) print(`Add them under "hooks" in ${r.userFile} — events: ${HOOK_EVENTS.join(", ")}.`, "dim");
33
36
  }
34
37
  if (nUser) {
35
- print(`Yours (${userSettingsPath()}):`, "ok");
38
+ print(`Yours (${r.userFile}):`, "ok");
36
39
  printGroups(r.user, (l) => print(l, "dim"));
37
40
  }
38
41
  if (nProj) {
39
42
  print(
40
43
  r.projectApproved
41
- ? "This project's (./.gonext/settings.json) — approved:"
42
- : "This project's (./.gonext/settings.json) — NOT running until you approve them:",
44
+ ? `This project's (${r.projectFile}) — approved:`
45
+ : `This project's (${r.projectFile}) — NOT running until you approve them:`,
43
46
  r.projectApproved ? "ok" : "warn"
44
47
  );
45
48
  printGroups(r.project, (l) => print(l, "dim"));
46
49
  if (!r.projectApproved) print("Check the commands above, then: /hooks approve", "dim");
47
50
  }
51
+ // What Claude Code would run and gu does not — a guard nobody runs must not look active.
52
+ for (const n of r.notes) print(n, "warn");
48
53
  return 0;
49
54
  }
50
55
  if (sub === "approve" || sub === "unapprove") {
51
56
  const r = readHooks({ cwd });
52
57
  if (!Object.keys(r.project).length) {
53
- print("This folder has no hooks of its own (./.gonext/settings.json).", "warn");
58
+ print(`This folder has no hooks of its own (${r.projectFile}).`, "warn");
54
59
  return 1;
55
60
  }
56
61
  approveProjectHooks({ cwd, approve: sub === "approve" });
package/hooks-config.mjs CHANGED
@@ -18,6 +18,21 @@
18
18
  * them for that folder — and the approval is of their exact content (a hash): an edited hooks
19
19
  * block needs approving again. The approval lives in YOUR settings file, never the project's.
20
20
  *
21
+ * CLAUDE CODE'S HOOKS (#325). A folder that chose "Claude's skills" (/switch-skill) reads its hooks
22
+ * from Claude Code's own settings instead:
23
+ * ~/.claude/settings.json yours
24
+ * <project>/.claude/settings.json, .claude/settings.local.json the project's — approved here first
25
+ * Those are filed under CLAUDE's event names and match CLAUDE's tool names, and the commands expect
26
+ * Claude's input. So each group is marked `dialect: "claude"`, and the agent speaks Claude's
27
+ * vocabulary to it (gu_hooks.py): SessionStart / UserPromptSubmit / PostToolUse / Stop at gu's four
28
+ * moments, Edit / Write / Read / Grep / Glob / Bash for gu's tools, Claude's field names on stdin.
29
+ * Tested with a real Claude memory hook: with the folder switched and nothing translated it never
30
+ * ran; with only the event names translated it ran and recorded nothing.
31
+ * What gu cannot honour is SAID, never skipped in silence: an event gu has no moment for
32
+ * (PreToolUse — a guard someone may be relying on), a hook that is not a command, a hook limited by
33
+ * an `if` rule gu cannot evaluate. Read-only: gu never writes Claude's settings files; the approval
34
+ * of a project's hooks is still kept in ~/.gonext/settings.json.
35
+ *
21
36
  * THE SESSION. onStart fires on the first turn of a gu session. The terminal writes a new session
22
37
  * id into the folder's session file when it starts (and on /reset); sessionForTurn reads it back so
23
38
  * the agent can tell "new session" from "next turn" with nothing added to the API.
@@ -26,6 +41,7 @@ import { createHash } from "node:crypto";
26
41
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
27
42
  import { homedir } from "node:os";
28
43
  import { dirname, join, resolve } from "node:path";
44
+ import { resolveSkillSource, sessionFileFor, sourceDirName } from "./skill-source.mjs";
29
45
 
30
46
  export const HOOK_EVENTS = ["onStart", "onAsk", "onToolDone", "onTurnEnd"];
31
47
  /** Claude Code's names → gu's. Only used to tell the user what to rename. */
@@ -36,8 +52,13 @@ export const CLAUDE_EVENT_NAMES = {
36
52
  Stop: "onTurnEnd",
37
53
  };
38
54
 
39
- export const userSettingsPath = (home = homedir()) => join(home, ".gonext", "settings.json");
40
- export const projectSettingsPath = (cwd) => join(cwd, ".gonext", "settings.json");
55
+ /** gu's moment → Claude Code's event: the name a Claude hook is filed under, and is told it is. */
56
+ export const GU_TO_CLAUDE_EVENT = Object.fromEntries(Object.entries(CLAUDE_EVENT_NAMES).map(([c, g]) => [g, c]));
57
+
58
+ export const userSettingsPath = (home = homedir(), source = "gu") => join(home, sourceDirName(source), "settings.json");
59
+ export const projectSettingsPath = (cwd, source = "gu") => join(cwd, sourceDirName(source), "settings.json");
60
+ /** Claude Code's per-project file that is not committed — still a file in the project's folder. */
61
+ export const projectLocalSettingsPath = (cwd) => join(cwd, ".claude", "settings.local.json");
41
62
 
42
63
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
43
64
 
@@ -56,11 +77,54 @@ function readJson(file) {
56
77
  }
57
78
  }
58
79
 
59
- /** A settings file's hooks, cleaned: only gu events, only command hooks. → { hooks, warnings } */
60
- export function cleanHooks(raw, where) {
80
+ /**
81
+ * A settings file's hooks, cleaned: only gu events, only command hooks. → { hooks, warnings, notes }
82
+ *
83
+ * `source: "claude"` reads the file as Claude Code wrote it: the keys are Claude's event names, the
84
+ * result is filed under gu's four moments with `dialect: "claude"` on every group. `notes` name
85
+ * what was left out and why — said by /hooks, not at every start.
86
+ */
87
+ export function cleanHooks(raw, where, source = "gu") {
61
88
  const hooks = {};
62
89
  const warnings = [];
63
- if (!isObj(raw)) return { hooks, warnings };
90
+ const notes = [];
91
+ if (!isObj(raw)) return { hooks, warnings, notes };
92
+ if (source === "claude") {
93
+ for (const [event, groups] of Object.entries(raw)) {
94
+ const gu = CLAUDE_EVENT_NAMES[event];
95
+ const list = Array.isArray(groups) ? groups.filter(isObj) : [];
96
+ if (!gu) {
97
+ const n = list.reduce((a, g) => a + (Array.isArray(g.hooks) ? g.hooks.length : 0), 0);
98
+ if (n) notes.push(`${where}: ${n} ${event} hook${n === 1 ? "" : "s"} — gu has no such moment, so ${n === 1 ? "it is" : "they are"} NOT run here.`);
99
+ continue;
100
+ }
101
+ for (const g of list) {
102
+ const hs = [];
103
+ for (const h of Array.isArray(g.hooks) ? g.hooks : []) {
104
+ if (!isObj(h)) continue;
105
+ const type = String(h.type ?? "command");
106
+ if (type !== "command") {
107
+ notes.push(`${where}: a ${event} hook of type "${type}" — gu runs command hooks only. Not run here.`);
108
+ continue;
109
+ }
110
+ if (!String(h.command ?? "").trim()) continue;
111
+ if (h.if !== undefined) {
112
+ // Narrowed by a permission rule gu cannot evaluate: running it everywhere would be wrong.
113
+ notes.push(`${where}: a ${event} hook limited by if: ${JSON.stringify(h.if)} — gu cannot evaluate that. Not run here.`);
114
+ continue;
115
+ }
116
+ hs.push({
117
+ type: "command",
118
+ command: String(h.command),
119
+ ...(Array.isArray(h.args) ? { args: h.args.map(String) } : {}),
120
+ ...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
121
+ });
122
+ }
123
+ if (hs.length) (hooks[gu] ??= []).push({ matcher: String(g.matcher ?? ""), hooks: hs, dialect: "claude" });
124
+ }
125
+ }
126
+ return { hooks, warnings, notes };
127
+ }
64
128
  for (const [event, groups] of Object.entries(raw)) {
65
129
  if (!HOOK_EVENTS.includes(event)) {
66
130
  const gu = CLAUDE_EVENT_NAMES[event];
@@ -87,7 +151,7 @@ export function cleanHooks(raw, where) {
87
151
  }
88
152
  if (kept.length) hooks[event] = kept;
89
153
  }
90
- return { hooks, warnings };
154
+ return { hooks, warnings, notes };
91
155
  }
92
156
 
93
157
  /** The exact content being approved — any edit changes it. */
@@ -95,36 +159,72 @@ export function hooksFingerprint(hooks) {
95
159
  return createHash("sha256").update(JSON.stringify(hooks ?? {})).digest("hex").slice(0, 16);
96
160
  }
97
161
 
98
- /** → { user, project, projectApproved, projectPending, warnings } */
99
- export function readHooks({ cwd, home = homedir() } = {}) {
162
+ /** How each source's files are NAMED to the user (labels in messages — not paths to open). */
163
+ const FILE_LABELS = {
164
+ gu: { user: "~/.gonext/settings.json", project: ".gonext/settings.json", shown: "./.gonext/settings.json" },
165
+ claude: { user: "~/.claude/settings.json", project: ".claude/settings.json", shown: "./.claude/settings.json" },
166
+ };
167
+
168
+ /** Where a folder's approval is kept in gu's settings: one slot per folder and per source. */
169
+ const approvalKey = (cwd, source) => resolve(cwd) + (source === "claude" ? "#claude" : "");
170
+
171
+ /** Two hook maps, one after the other (Claude's project file, then its local one). */
172
+ function mergeHooks(a, b) {
173
+ const out = { ...a };
174
+ for (const [ev, groups] of Object.entries(b)) out[ev] = [...(out[ev] ?? []), ...groups];
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * `source` = whose hooks ("gu" | "claude"); left out, it is the folder's own choice.
180
+ * → { user, project, projectApproved, projectPending, warnings, notes, source, userFile, projectFile }
181
+ */
182
+ export function readHooks({ cwd, home = homedir(), source } = {}) {
183
+ const src = resolveSkillSource({ source, cwd, home });
184
+ const label = FILE_LABELS[src];
100
185
  const warnings = [];
101
- const u = readJson(userSettingsPath(home));
102
- if (u.error) warnings.push(`${userSettingsPath(home)} is ${u.error} — its hooks are skipped`);
103
- const user = cleanHooks(u.data?.hooks, "~/.gonext/settings.json");
186
+ const notes = [];
187
+ const userFile = userSettingsPath(home, src);
188
+ const u = readJson(userFile);
189
+ if (u.error) warnings.push(`${userFile} is ${u.error} — its hooks are skipped`);
190
+ const user = cleanHooks(u.data?.hooks, label.user, src);
104
191
  warnings.push(...user.warnings);
105
- let project = { hooks: {}, warnings: [] };
192
+ notes.push(...user.notes);
193
+ let project = {};
106
194
  let projectApproved = false;
107
- if (cwd && resolve(projectSettingsPath(cwd)) !== resolve(userSettingsPath(home))) {
108
- const p = readJson(projectSettingsPath(cwd));
109
- if (p.error) warnings.push(`${projectSettingsPath(cwd)} is ${p.error} — its hooks are skipped`);
110
- project = cleanHooks(p.data?.hooks, ".gonext/settings.json");
111
- warnings.push(...project.warnings);
112
- const approved = u.data?.approvedProjectHooks?.[resolve(cwd)];
113
- projectApproved = Object.keys(project.hooks).length > 0 && approved === hooksFingerprint(project.hooks);
195
+ if (cwd && resolve(projectSettingsPath(cwd, src)) !== resolve(userFile)) {
196
+ const files = [[projectSettingsPath(cwd, src), label.project]];
197
+ if (src === "claude") files.push([projectLocalSettingsPath(cwd), ".claude/settings.local.json"]);
198
+ for (const [file, where] of files) {
199
+ const p = readJson(file);
200
+ if (p.error) warnings.push(`${file} is ${p.error} — its hooks are skipped`);
201
+ const cleaned = cleanHooks(p.data?.hooks, where, src);
202
+ warnings.push(...cleaned.warnings);
203
+ notes.push(...cleaned.notes);
204
+ project = mergeHooks(project, cleaned.hooks);
205
+ }
206
+ // gu's OWN settings file holds the approval, whichever source the hooks came from.
207
+ const own = src === "gu" ? u : readJson(userSettingsPath(home));
208
+ const approved = own.data?.approvedProjectHooks?.[approvalKey(cwd, src)];
209
+ projectApproved = Object.keys(project).length > 0 && approved === hooksFingerprint(project);
114
210
  }
115
- const hasProject = Object.keys(project.hooks).length > 0;
211
+ const hasProject = Object.keys(project).length > 0;
116
212
  return {
117
213
  user: user.hooks,
118
- project: project.hooks,
214
+ project,
119
215
  projectApproved,
120
216
  projectPending: hasProject && !projectApproved,
121
217
  warnings,
218
+ notes,
219
+ source: src,
220
+ userFile,
221
+ projectFile: label.shown,
122
222
  };
123
223
  }
124
224
 
125
225
  /** What one turn runs: yours first, then the project's when approved. */
126
- export function hooksForTurn({ cwd, home = homedir() } = {}) {
127
- const r = readHooks({ cwd, home });
226
+ export function hooksForTurn({ cwd, home = homedir(), source } = {}) {
227
+ const r = readHooks({ cwd, home, source });
128
228
  const out = {};
129
229
  for (const ev of HOOK_EVENTS) {
130
230
  const groups = [...(r.user[ev] ?? []), ...(r.projectApproved ? r.project[ev] ?? [] : [])];
@@ -134,15 +234,15 @@ export function hooksForTurn({ cwd, home = homedir() } = {}) {
134
234
  }
135
235
 
136
236
  /** Approve (or withdraw) this folder's hooks, as they are right now. */
137
- export function approveProjectHooks({ cwd, home = homedir(), approve = true }) {
138
- const r = readHooks({ cwd, home });
139
- const file = userSettingsPath(home);
237
+ export function approveProjectHooks({ cwd, home = homedir(), approve = true, source }) {
238
+ const r = readHooks({ cwd, home, source });
239
+ const file = userSettingsPath(home); // gu's own, always — Claude's settings are never written
140
240
  const { data, error } = readJson(file);
141
241
  if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
142
242
  const raw = data ?? {};
143
243
  raw.approvedProjectHooks = isObj(raw.approvedProjectHooks) ? raw.approvedProjectHooks : {};
144
- if (approve) raw.approvedProjectHooks[resolve(cwd)] = hooksFingerprint(r.project);
145
- else delete raw.approvedProjectHooks[resolve(cwd)];
244
+ if (approve) raw.approvedProjectHooks[approvalKey(cwd, r.source)] = hooksFingerprint(r.project);
245
+ else delete raw.approvedProjectHooks[approvalKey(cwd, r.source)];
146
246
  mkdirSync(dirname(file), { recursive: true });
147
247
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
148
248
  writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
@@ -152,11 +252,8 @@ export function approveProjectHooks({ cwd, home = homedir(), approve = true }) {
152
252
 
153
253
  /* ---------------------------------------------------------------- the session ------------ */
154
254
 
155
- /** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
156
- export function sessionFileFor(cwd, home = homedir()) {
157
- const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
158
- return join(home, ".gonext", "sessions", `${hash}.json`);
159
- }
255
+ /** The terminal's per-folder session file — the same name gu-repl.mjs uses (skill-source.mjs). */
256
+ export { sessionFileFor };
160
257
 
161
258
  /** → { id, startedAt } | null. null = no terminal session (the web, or an older terminal). */
162
259
  export function sessionForTurn(cwd, home = homedir()) {
package/mcp-command.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  removeServer,
32
32
  setDisabled,
33
33
  userConfigPath,
34
+ userServersPath,
34
35
  SERVER_NAME_RE,
35
36
  } from "./mcp-config.mjs";
36
37
 
@@ -146,15 +147,20 @@ export async function runMcpCommand(words, { cwd = process.cwd(), print = consol
146
147
  switch (sub) {
147
148
  case "list":
148
149
  case "ls": {
149
- const { servers, warnings } = listServers({ cwd });
150
+ const { servers, warnings, source } = listServers({ cwd });
150
151
  for (const x of warnings) print(x, "warn");
151
152
  if (!servers.length) {
152
- print("No MCP servers yet.");
153
+ print(source === "claude" ? `No MCP servers in Claude Code's setup (${userServersPath(undefined, source)}, ./.mcp.json).` : "No MCP servers yet.");
153
154
  print(`Add one: /mcp add (or in a shell: ${ADD_USAGE})`, "dim");
154
155
  return 0;
155
156
  }
156
157
  const results = await probe(servers.filter((s) => s.runnable), { cwd });
157
- print(`MCP servers — yours in ${userConfigPath()}, this folder's in ./.mcp.json`, "dim");
158
+ print(
159
+ source === "claude"
160
+ ? `MCP servers — Claude Code's: yours in ${userServersPath(undefined, source)}, this folder's in ./.mcp.json (/switch-skill)`
161
+ : `MCP servers — yours in ${userConfigPath()}, this folder's in ./.mcp.json`,
162
+ "dim"
163
+ );
158
164
  for (const s of servers) {
159
165
  print(statusLine(s, results[s.name]), results[s.name]?.ok ? "ok" : undefined);
160
166
  if (!s.error) print(` ${describeServer(s)}`, "dim");
@@ -233,8 +239,8 @@ export async function runMcpCommand(words, { cwd = process.cwd(), print = consol
233
239
  print(`"${name}" is this project's (./.mcp.json) — it will not start here any more. Edit that file to delete it.`);
234
240
  return 0;
235
241
  }
236
- const found = removeServer({ name });
237
- print(found ? `Removed "${name}".` : `No MCP server named "${name}" in ${userConfigPath()}.`, found ? "ok" : "warn");
242
+ const found = removeServer({ name, cwd });
243
+ print(found ? `Removed "${name}".` : `No MCP server named "${name}" in ${userServersPath(undefined, listServers({ cwd }).source)}.`, found ? "ok" : "warn");
238
244
  return found ? 0 : 1;
239
245
  }
240
246
  case "enable":
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.784",
3
+ "version": "1.0.785",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "check:image": "node scripts/image-paste-check.mjs",
42
42
  "check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
43
43
  "test:presets": "node --test tests/open-presets.test.mjs tests/preset-safety.test.mjs tests/preset-more.test.mjs tests/preset-apply.test.mjs tests/preset-full.test.mjs tests/presets-transfer.test.mjs tests/preset-editor.test.mjs tests/load-preset-paste.test.mjs",
44
- "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
44
+ "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_claude_hooks.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/switch-skill.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
45
45
  "capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
46
46
  "test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
47
47
  "test:bearer-e2e": "bash scripts/bearer-e2e.sh"
@@ -109,6 +109,7 @@
109
109
  "powershell-alias.mjs",
110
110
  "install-path.mjs",
111
111
  "skills.mjs",
112
+ "skill-source.mjs",
112
113
  "mcp-config.mjs",
113
114
  "mcp-command.mjs",
114
115
  "hooks-config.mjs",