@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/gu_hooks.py CHANGED
@@ -17,6 +17,18 @@ A hook that fails any other way, or runs past its timeout, is a warning — neve
17
17
 
18
18
  WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
19
19
  credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
20
+
21
+ CLAUDE CODE'S HOOKS (task #325). A folder that chose "Claude's skills" hands over hooks read from
22
+ Claude Code's settings; their groups carry dialect "claude". Those commands were written for Claude
23
+ Code, so gu speaks Claude's vocabulary TO THEM and to nobody else:
24
+ the event SessionStart / UserPromptSubmit / PostToolUse / Stop, as "hook_event_name"
25
+ the tool Edit, Write, Read, Grep, Glob, Bash — for the matcher AND in the input, with
26
+ Claude's argument names (file_path, not path)
27
+ the fields prompt (question), tool_response (tool_result), last_assistant_message (answer)
28
+ the answer {"hookSpecificOutput": {"additionalContext": ...}} is read like gu's own shape
29
+ Measured with a real Claude memory hook: given gu's names it never ran; given Claude's event names
30
+ but gu's tool names and fields it ran and recorded nothing; given all of the above it recorded the
31
+ lookups and edits (with line ranges) and gave the next session its memory back.
20
32
  """
21
33
  from __future__ import annotations
22
34
 
@@ -36,6 +48,62 @@ _ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TM
36
48
  _PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
37
49
 
38
50
 
51
+ # ---- Claude Code's vocabulary (#325) -----------------------------------------------------------
52
+
53
+ CLAUDE_EVENT = {"onStart": "SessionStart", "onAsk": "UserPromptSubmit",
54
+ "onToolDone": "PostToolUse", "onTurnEnd": "Stop"}
55
+
56
+
57
+ def claude_tool(name: str, args: Any) -> tuple[str, dict]:
58
+ """gu's tool call as Claude Code would have named it. Unknown tools (mcp__server__tool, which
59
+ both spell the same way) pass through unchanged."""
60
+ a = dict(args) if isinstance(args, dict) else {}
61
+ path = a.get("path")
62
+ if name == "edit_file":
63
+ return "Edit", {"file_path": path, "old_string": a.get("old_string", ""),
64
+ "new_string": a.get("new_string", "")}
65
+ if name == "edit_lines":
66
+ # No old text to give: the new text is what a hook looks for in the file.
67
+ return "Edit", {"file_path": path, "old_string": "", "new_string": a.get("new_content", ""),
68
+ "start_line": a.get("start_line"), "end_line": a.get("end_line")}
69
+ if name == "create_file":
70
+ return "Write", {"file_path": path, "content": a.get("content", "")}
71
+ if name in ("read_file_lines", "read_text_file"):
72
+ out = {"file_path": path}
73
+ if a.get("start_line"):
74
+ out["offset"] = a.get("start_line")
75
+ return "Read", out
76
+ if name == "grep_repo":
77
+ return "Grep", {"pattern": a.get("pattern", ""), "path": path or "", "glob": a.get("glob", "")}
78
+ if name == "symbol_context":
79
+ return "Grep", {"pattern": a.get("name", ""), "path": path or ""}
80
+ if name == "list_dir":
81
+ return "Glob", {"pattern": (str(path or ".").rstrip("/\\") or ".") + "/*", "path": path or ""}
82
+ if name == "run_command":
83
+ return "Bash", {"command": a.get("command", "")}
84
+ return name, a
85
+
86
+
87
+ def claude_payload(event: str, payload: dict) -> dict:
88
+ """One gu event's payload with Claude Code's field names."""
89
+ out = dict(payload)
90
+ if event == "onStart":
91
+ out.setdefault("source", "startup")
92
+ elif event == "onAsk":
93
+ out["prompt"] = out.pop("question", "")
94
+ elif event == "onToolDone":
95
+ out["tool_name"], out["tool_input"] = claude_tool(str(out.get("tool_name", "")), out.get("tool_input"))
96
+ out["tool_response"] = out.pop("tool_result", "")
97
+ elif event == "onTurnEnd":
98
+ out["last_assistant_message"] = out.pop("answer", "")
99
+ out["stop_hook_active"] = False
100
+ return out
101
+
102
+
103
+ def is_claude(group: Any) -> bool:
104
+ return isinstance(group, dict) and group.get("dialect") == "claude"
105
+
106
+
39
107
  def matches(matcher: str | None, value: str) -> bool:
40
108
  """Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
41
109
  m = (matcher or "").strip()
@@ -76,11 +144,24 @@ class HookRunner:
76
144
  def matcher_union(self, event: str) -> list[str]:
77
145
  return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
78
146
 
147
+ def wants_tool(self, name: str) -> bool:
148
+ """Does any onToolDone group name this tool — in gu's name for gu's hooks, in Claude Code's
149
+ for Claude's (a matcher "Edit|Write" means edit_file and create_file)."""
150
+ for g in self.hooks.get("onToolDone") or []:
151
+ # A group with nothing to run wraps no tool, whatever its matcher says.
152
+ if not isinstance(g, dict) or not any(isinstance(h, dict) and h.get("command") for h in g.get("hooks") or []):
153
+ continue
154
+ value = claude_tool(name, {})[0] if is_claude(g) else name
155
+ if matches(g.get("matcher"), value):
156
+ return True
157
+ return False
158
+
79
159
  def _env(self, event: str, extra: dict | None) -> dict:
80
160
  env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
81
161
  env.setdefault("PYTHONIOENCODING", "utf-8")
82
162
  env["GU_PROJECT_DIR"] = self.cwd or ""
83
163
  env["GU_HOOK_EVENT"] = event
164
+ env["CLAUDE_PROJECT_DIR"] = self.cwd or "" # what a hook written for Claude Code reads
84
165
  for k, v in (extra or {}).items():
85
166
  env[str(k)] = str(v)
86
167
  return env
@@ -91,13 +172,27 @@ class HookRunner:
91
172
  return res
92
173
  body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
93
174
  data = json.dumps(body, ensure_ascii=False, default=str)
175
+ claude_data = claude_match = None # built once, and only if a Claude hook is there
94
176
  for group in self.hooks.get(event) or []:
95
- if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
177
+ if not isinstance(group, dict):
178
+ continue
179
+ if is_claude(group):
180
+ if claude_data is None:
181
+ cbody = claude_payload(event, payload)
182
+ claude_match = str(cbody.get("tool_name", "")) if event == "onToolDone" else match_value
183
+ claude_data = json.dumps({"hook_event_name": CLAUDE_EVENT[event],
184
+ "session_id": self.session_id, "cwd": self.cwd or "",
185
+ "permission_mode": "default", **cbody},
186
+ ensure_ascii=False, default=str)
187
+ value, stdin = claude_match, claude_data
188
+ else:
189
+ value, stdin = match_value, data
190
+ if not matches(group.get("matcher"), value):
96
191
  continue
97
192
  for h in group.get("hooks") or []:
98
193
  if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
99
194
  continue
100
- self._one(event, h, data, res)
195
+ self._one(event, h, stdin, res)
101
196
  return res
102
197
 
103
198
  def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
@@ -143,6 +238,10 @@ class HookRunner:
143
238
  res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
144
239
  return
145
240
  ctx = obj.get("additionalContext")
241
+ if not isinstance(ctx, str):
242
+ # Claude Code's shape: {"hookSpecificOutput": {"hookEventName", "additionalContext"}}
243
+ inner = obj.get("hookSpecificOutput")
244
+ ctx = inner.get("additionalContext") if isinstance(inner, dict) else None
146
245
  if isinstance(ctx, str) and ctx.strip():
147
246
  res.context.append(ctx)
148
247
 
@@ -185,11 +284,10 @@ def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
185
284
  context is appended to the tool's result; a block (exit 2) is appended as a message."""
186
285
  if not runner.has("onToolDone"):
187
286
  return 0
188
- matchers = runner.matcher_union("onToolDone")
189
287
  n = 0
190
288
  for t in tools:
191
289
  name = getattr(t, "name", "")
192
- if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
290
+ if not name or name == "final_answer" or not runner.wants_tool(name):
193
291
  continue
194
292
  orig = t.forward
195
293
  order = list((getattr(t, "inputs", None) or {}).keys())
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":