@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/completed-flush.mjs +47 -0
- package/gonext_agent_chat.py +1 -1
- package/gu-cli.mjs +9 -3
- package/gu-repl.mjs +473 -35
- package/gu_hooks.py +102 -4
- package/gu_skills.py +64 -4
- package/hooks-command.mjs +14 -9
- package/hooks-config.mjs +130 -33
- package/mcp-command.mjs +11 -5
- package/mcp-config.mjs +99 -11
- package/model-prices.mjs +484 -0
- package/package.json +4 -2
- package/skill-source.mjs +60 -0
- package/skills.mjs +24 -12
- package/step-tokens.mjs +45 -0
- package/terminal-layout.mjs +14 -0
- package/terminal-viewport.mjs +65 -0
- package/turn-stop.mjs +17 -0
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)
|
|
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,
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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 (${
|
|
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
|
-
?
|
|
42
|
-
:
|
|
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(
|
|
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
|
-
|
|
40
|
-
export const
|
|
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
|
-
/**
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
const
|
|
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
|
-
|
|
192
|
+
notes.push(...user.notes);
|
|
193
|
+
let project = {};
|
|
106
194
|
let projectApproved = false;
|
|
107
|
-
if (cwd && resolve(projectSettingsPath(cwd)) !== resolve(
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
211
|
+
const hasProject = Object.keys(project).length > 0;
|
|
116
212
|
return {
|
|
117
213
|
user: user.hooks,
|
|
118
|
-
project
|
|
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[
|
|
145
|
-
else delete raw.approvedProjectHooks[
|
|
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
|
|
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(
|
|
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 ${
|
|
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":
|