@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/completed-flush.mjs +47 -0
- package/gonext_agent_chat.py +1 -1
- package/gu-repl.mjs +154 -26
- 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/package.json +3 -2
- package/skill-source.mjs +60 -0
- package/skills.mjs +24 -12
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":
|
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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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",
|