@tiens.nguyen/gu-cli 1.0.784 → 1.0.786
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 +252 -39
- 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 +4 -2
- package/skill-source.mjs +60 -0
- package/skills.mjs +24 -12
- package/step-group.mjs +140 -0
- package/terminal-layout.mjs +16 -2
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.786",
|
|
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,8 @@
|
|
|
109
109
|
"powershell-alias.mjs",
|
|
110
110
|
"install-path.mjs",
|
|
111
111
|
"skills.mjs",
|
|
112
|
+
"skill-source.mjs",
|
|
113
|
+
"step-group.mjs",
|
|
112
114
|
"mcp-config.mjs",
|
|
113
115
|
"mcp-command.mjs",
|
|
114
116
|
"hooks-config.mjs",
|
package/skill-source.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHOSE SKILLS THIS FOLDER USES — gu's or Claude Code's (task #325, /switch-skill).
|
|
3
|
+
*
|
|
4
|
+
* "gu" ~/.gonext and <project>/.gonext (the default; a folder that never chose)
|
|
5
|
+
* "claude" ~/.claude and <project>/.claude Claude Code's own files, read where they are
|
|
6
|
+
*
|
|
7
|
+
* One choice covers skills, MCP servers and hooks, because they depend on each other: a skill names
|
|
8
|
+
* an MCP server's tools, and the hooks feed the same database that server reads. Switching one of
|
|
9
|
+
* the three alone would leave them talking about different things.
|
|
10
|
+
*
|
|
11
|
+
* ONE-WAY. gu reads Claude Code's setup; Claude Code is never expected to read gu's. So nothing of
|
|
12
|
+
* gu's own is written into Claude's files: which project servers and hooks you approved, and the
|
|
13
|
+
* allow rules, stay in ~/.gonext whichever source is chosen.
|
|
14
|
+
*
|
|
15
|
+
* PER FOLDER, IN THE FOLDER'S SESSION FILE. The terminal writes it; the worker — a different
|
|
16
|
+
* process, which builds each turn's skills, servers and hooks — reads the same file. Every reader
|
|
17
|
+
* in this package resolves the source through readSkillSource, so the banner and the turn cannot
|
|
18
|
+
* disagree about which setup is in use.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
export const SKILL_SOURCES = ["gu", "claude"];
|
|
26
|
+
export const DEFAULT_SKILL_SOURCE = "gu";
|
|
27
|
+
/** The session file's key. */
|
|
28
|
+
export const SKILL_SOURCE_KEY = "skillSource";
|
|
29
|
+
|
|
30
|
+
export const normalizeSkillSource = (v) => (v === "claude" ? "claude" : DEFAULT_SKILL_SOURCE);
|
|
31
|
+
|
|
32
|
+
/** The folder each source keeps its files in — under the home directory and under the project. */
|
|
33
|
+
export const sourceDirName = (source) => (normalizeSkillSource(source) === "claude" ? ".claude" : ".gonext");
|
|
34
|
+
|
|
35
|
+
/** What a picker and a status line call it. */
|
|
36
|
+
export const sourceLabel = (source) => (normalizeSkillSource(source) === "claude" ? "Claude's skills" : "gu's skills");
|
|
37
|
+
|
|
38
|
+
/** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
|
|
39
|
+
export function sessionFileFor(cwd, home = homedir()) {
|
|
40
|
+
const hash = createHash("sha256").update(String(cwd)).digest("hex").slice(0, 32);
|
|
41
|
+
return join(home, ".gonext", "sessions", `${hash}.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This folder's choice. No folder, no session file, a file that cannot be read, any other value:
|
|
46
|
+
* gu's — the behaviour before there was a choice, and never an error.
|
|
47
|
+
*/
|
|
48
|
+
export function readSkillSource(cwd, home = homedir()) {
|
|
49
|
+
if (!cwd) return DEFAULT_SKILL_SOURCE;
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(sessionFileFor(cwd, home), "utf8"));
|
|
52
|
+
return normalizeSkillSource(raw?.[SKILL_SOURCE_KEY]);
|
|
53
|
+
} catch {
|
|
54
|
+
return DEFAULT_SKILL_SOURCE;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `source` when a caller names one, else the folder's own. */
|
|
59
|
+
export const resolveSkillSource = ({ source, cwd, home = homedir() } = {}) =>
|
|
60
|
+
source ? normalizeSkillSource(source) : readSkillSource(cwd, home);
|
package/skills.mjs
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* ~/.gonext/skills/<name>/SKILL.md yours, every folder
|
|
5
5
|
* <project>/.gonext/skills/<name>/SKILL.md this project only
|
|
6
6
|
*
|
|
7
|
+
* …or Claude Code's own, when the folder chose them with /switch-skill (#325, skill-source.mjs):
|
|
8
|
+
* ~/.claude/skills/<name>/SKILL.md and <project>/.claude/skills/<name>/SKILL.md
|
|
9
|
+
* The same file in the same layout — only the folder differs, so nothing is converted.
|
|
10
|
+
*
|
|
7
11
|
* A SKILL.md is YAML frontmatter (name, description) and a body of instructions. Only the name and
|
|
8
12
|
* description go into every prompt; the body is read when the agent calls use_skill(name) or the
|
|
9
13
|
* user types /<name> — never pasted into every turn. Same split as Claude Code, and for the same
|
|
@@ -24,16 +28,17 @@
|
|
|
24
28
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
25
29
|
import { homedir } from "node:os";
|
|
26
30
|
import { join } from "node:path";
|
|
31
|
+
import { resolveSkillSource, sourceDirName } from "./skill-source.mjs";
|
|
27
32
|
|
|
28
33
|
export const SKILL_FILE = "SKILL.md";
|
|
29
34
|
/** Claude Code's rule: lowercase letters, digits and hyphens, at most 64. It is also a slash command. */
|
|
30
35
|
export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
31
36
|
|
|
32
|
-
export function userSkillsDir(home = homedir()) {
|
|
33
|
-
return join(home,
|
|
37
|
+
export function userSkillsDir(home = homedir(), source = "gu") {
|
|
38
|
+
return join(home, sourceDirName(source), "skills");
|
|
34
39
|
}
|
|
35
|
-
export function projectSkillsDir(cwd) {
|
|
36
|
-
return join(cwd,
|
|
40
|
+
export function projectSkillsDir(cwd, source = "gu") {
|
|
41
|
+
return join(cwd, sourceDirName(source), "skills");
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** Strip one pair of matching quotes. */
|
|
@@ -138,13 +143,17 @@ function scanDir(dir, scope) {
|
|
|
138
143
|
* the built-in wins, and the skill is still usable by the agent, just not as a slash command —
|
|
139
144
|
* that is `slash: false`.
|
|
140
145
|
*
|
|
141
|
-
*
|
|
146
|
+
* `source` = whose skills ("gu" | "claude"); left out, it is the folder's own choice (/switch-skill).
|
|
147
|
+
* Only that source's skills are listed — the two are never mixed.
|
|
148
|
+
*
|
|
149
|
+
* → { skills: [{ name, description, path, dir, scope, slash, source }], warnings: string[], source }
|
|
142
150
|
*/
|
|
143
|
-
export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [] } = {}) {
|
|
144
|
-
const
|
|
151
|
+
export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [], source } = {}) {
|
|
152
|
+
const src = resolveSkillSource({ source, cwd, home });
|
|
153
|
+
const user = scanDir(userSkillsDir(home, src), "user");
|
|
145
154
|
const project =
|
|
146
|
-
cwd && projectSkillsDir(cwd) !== userSkillsDir(home)
|
|
147
|
-
? scanDir(projectSkillsDir(cwd), "project")
|
|
155
|
+
cwd && projectSkillsDir(cwd, src) !== userSkillsDir(home, src)
|
|
156
|
+
? scanDir(projectSkillsDir(cwd, src), "project")
|
|
148
157
|
: { skills: [], warnings: [] };
|
|
149
158
|
const byName = new Map();
|
|
150
159
|
for (const s of user.skills) byName.set(s.name, s);
|
|
@@ -152,17 +161,20 @@ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved
|
|
|
152
161
|
const taken = new Set(reserved.map((r) => r.replace(/^\//, "")));
|
|
153
162
|
const skills = [...byName.values()]
|
|
154
163
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
155
|
-
.map((s) => ({ ...s, slash: !taken.has(s.name) }));
|
|
156
|
-
return { skills, warnings: [...user.warnings, ...project.warnings] };
|
|
164
|
+
.map((s) => ({ ...s, slash: !taken.has(s.name), source: src }));
|
|
165
|
+
return { skills, warnings: [...user.warnings, ...project.warnings], source: src };
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
/** What the worker hands the agent for one turn: enough to list them and to read one. */
|
|
160
169
|
export function skillsForTurn(opts) {
|
|
161
|
-
|
|
170
|
+
// `source` goes along only for Claude's: the agent then reads the skill as one written for
|
|
171
|
+
// Claude Code's tools (gu_skills.py). gu's own keep the shape they always had.
|
|
172
|
+
return discoverSkills(opts).skills.map(({ name, description, path, dir, source }) => ({
|
|
162
173
|
name,
|
|
163
174
|
description,
|
|
164
175
|
path,
|
|
165
176
|
dir,
|
|
177
|
+
...(source === "claude" ? { source } : {}),
|
|
166
178
|
}));
|
|
167
179
|
}
|
|
168
180
|
|
package/step-group.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TURN'S LOOK-UPS AS ONE LINE (task #326) — pure helpers; gu-repl.mjs does the drawing.
|
|
3
|
+
*
|
|
4
|
+
* Asked for after reading a Claude Code session beside a gu one. gu printed one "●" line per tool
|
|
5
|
+
* call — "Searching code → …", "Reading lines → …", again and again — so a turn that read six
|
|
6
|
+
* files and searched five times was thirty rows of scaffolding above a ten-row answer. Claude Code
|
|
7
|
+
* says the same thing in one line ("Searched for 2 patterns, read 6 files") and keeps the detail a
|
|
8
|
+
* click away. That is what a GROUP is: consecutive look-ups share one row, counted by kind, and a
|
|
9
|
+
* click opens it into the lines gu used to print.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS GROUPED: a neutral step of the shape "Label → detail" — the agent telling us what it
|
|
12
|
+
* looked at. NOT grouped, because each is something the user must be able to see at a glance:
|
|
13
|
+
* a failure (✗), a command's result (✓), an edit card, an approval, the answer.
|
|
14
|
+
*
|
|
15
|
+
* NOTHING HERE KNOWS A TOOL'S NAME. The kind is whatever stands before the arrow, so a tool added
|
|
16
|
+
* tomorrow is counted with no change here — the same rule the tool list itself follows (#37).
|
|
17
|
+
*
|
|
18
|
+
* SCAFFOLD LINES are the turn's machinery talking about itself ("Starting 1 MCP server(s)…",
|
|
19
|
+
* "Composing answer…", the retrieval tally the group line now carries): live status, not history.
|
|
20
|
+
* Still printed under --verbose.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ARROW = " → ";
|
|
24
|
+
|
|
25
|
+
const SCAFFOLD = [/^Starting \d+ MCP server\(s\)…$/, /^Composing answer…$/, /^Found code by → /];
|
|
26
|
+
|
|
27
|
+
/** The turn's own machinery, not something it did for the user. */
|
|
28
|
+
export function isScaffoldStep(text) {
|
|
29
|
+
const t = String(text ?? "").trim();
|
|
30
|
+
return SCAFFOLD.some((re) => re.test(t));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "Searching code → seenNames (3 hits)" → "Searching code"; "" when the line has no such shape. */
|
|
34
|
+
export function stepLabel(text) {
|
|
35
|
+
const t = String(text ?? "").trim();
|
|
36
|
+
const at = t.indexOf(ARROW);
|
|
37
|
+
if (at <= 0 || at > 40) return ""; // a sentence that merely contains an arrow is not a step
|
|
38
|
+
const label = t.slice(0, at).trim();
|
|
39
|
+
// A label is a few words ("Indexing project for RAG" is the longest the agent writes) — more
|
|
40
|
+
// than four is prose that happens to hold an arrow.
|
|
41
|
+
if (label.split(/\s+/).length > 4) return "";
|
|
42
|
+
return /^[A-Za-z][A-Za-z0-9 _.\-/]*$/.test(label) ? label : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Does this step join a group? `kind` is bulletKind()'s: only neutral ("info") steps do. */
|
|
46
|
+
export function isGroupableStep(text, kind) {
|
|
47
|
+
return kind === "info" && !isScaffoldStep(text) && stepLabel(text) !== "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// "Searching code" reads as work in progress; a finished group is said in the past. Only the first
|
|
51
|
+
// word changes, and only when it is one of these — anything else is shown as the agent wrote it.
|
|
52
|
+
const PAST = {
|
|
53
|
+
searching: "searched", reading: "read", listing: "listed", locating: "located", exploring: "explored",
|
|
54
|
+
calculating: "calculated", downloading: "downloaded", fetching: "fetched", checking: "checked",
|
|
55
|
+
calling: "called", indexing: "indexed", sending: "sent", deploying: "deployed",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function pastLabel(label) {
|
|
59
|
+
const [first, ...rest] = label.split(" ");
|
|
60
|
+
const past = PAST[first.toLowerCase()];
|
|
61
|
+
return past ? [past, ...rest].join(" ") : label;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "searched code 3× · read lines 2× · listed files" — kinds in the order they first happened. */
|
|
65
|
+
export function stepGroupSummary(items) {
|
|
66
|
+
const counts = new Map();
|
|
67
|
+
for (const it of items ?? []) {
|
|
68
|
+
const label = stepLabel(it) || String(it ?? "").trim();
|
|
69
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
return [...counts].map(([label, n]) => pastLabel(label) + (n > 1 ? ` ${n}×` : "")).join(" · ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wrapWords(text, width) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
let cur = "";
|
|
77
|
+
for (const word of String(text).trim().split(/\s+/)) {
|
|
78
|
+
const next = cur ? cur + " " + word : word;
|
|
79
|
+
if (next.length <= width) cur = next;
|
|
80
|
+
else {
|
|
81
|
+
if (cur) rows.push(cur);
|
|
82
|
+
// a word longer than the row (a path) is cut into row-sized pieces, never dropped
|
|
83
|
+
let w = word;
|
|
84
|
+
while (w.length > width) {
|
|
85
|
+
rows.push(w.slice(0, width));
|
|
86
|
+
w = w.slice(width);
|
|
87
|
+
}
|
|
88
|
+
cur = w;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (cur) rows.push(cur);
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const clip = (text, width) => (text.length <= width ? text : text.slice(0, Math.max(1, width - 1)) + "…");
|
|
96
|
+
|
|
97
|
+
const EXPAND_HINT = " (click to expand)";
|
|
98
|
+
const COLLAPSE_HINT = "(click to collapse)";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The rows of one group: plain text, no colour, no gutter — the caller owns both.
|
|
102
|
+
* → [{ text, head }]; `head` marks a row that starts an entry (it gets the "●").
|
|
103
|
+
*
|
|
104
|
+
* COLLAPSED IS ALWAYS EXACTLY ONE ROW, whatever the width. The group is updated in place as the
|
|
105
|
+
* turn goes on, and a row that is always one row can be rewritten without moving anything below
|
|
106
|
+
* it — the lesson of every repaint bug in this terminal (#109, #321, #324): rows must not wrap,
|
|
107
|
+
* and what is on screen must stay what gu thinks is on screen.
|
|
108
|
+
* one look-up → the look-up itself (cut with "…" if it is too long; a click shows it whole)
|
|
109
|
+
* several → the counted summary, with the hint when there is room for it
|
|
110
|
+
*/
|
|
111
|
+
export function stepGroupRows(items, { width = 76, collapsed = true } = {}) {
|
|
112
|
+
const list = (items ?? []).map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
113
|
+
const w = Math.max(20, width);
|
|
114
|
+
if (!list.length) return [];
|
|
115
|
+
if (collapsed) {
|
|
116
|
+
if (list.length === 1) return [{ text: clip(list[0], w), head: true }];
|
|
117
|
+
const summary = stepGroupSummary(list);
|
|
118
|
+
const withHint = summary + EXPAND_HINT;
|
|
119
|
+
return [{ text: withHint.length <= w ? withHint : clip(summary, w), head: true }];
|
|
120
|
+
}
|
|
121
|
+
const rows = [];
|
|
122
|
+
for (const item of list) wrapWords(item, w).forEach((text, i) => rows.push({ text, head: i === 0 }));
|
|
123
|
+
// Only a group that HAS a shorter form offers to go back to it.
|
|
124
|
+
if (list.length > 1 || rows.length > 1) rows.push({ text: COLLAPSE_HINT, head: false });
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Paths under the project folder, shown relative to it: "/Users/x/proj/src/a.ts:1-9" → "src/a.ts:1-9".
|
|
130
|
+
* Display only. The folder itself becomes "." so "Listing files → /Users/x/proj" still says where.
|
|
131
|
+
*/
|
|
132
|
+
export function relativizePaths(text, cwd) {
|
|
133
|
+
const root = String(cwd ?? "").replace(/[\\/]+$/, "");
|
|
134
|
+
const src = String(text ?? "");
|
|
135
|
+
if (!root || root.length < 2 || !src.includes(root)) return src;
|
|
136
|
+
const esc = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
137
|
+
return src
|
|
138
|
+
.replace(new RegExp(esc + "[\\\\/]", "g"), "")
|
|
139
|
+
.replace(new RegExp(esc + "(?![\\w.-])", "g"), ".");
|
|
140
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -1038,6 +1038,10 @@ export function thoughtProse(buf) {
|
|
|
1038
1038
|
// A leading pipe or quote survives some streams ("| Thought: …").
|
|
1039
1039
|
.replace(/^\s*[|>\-]+\s*/, "")
|
|
1040
1040
|
.replace(/^\s*Thought:\s*/i, "")
|
|
1041
|
+
// …and the same label in the MIDDLE, glued to the sentence before it ("…the app does.Thought:
|
|
1042
|
+
// I have all the file contents") — a model that reasons and then restates under the label
|
|
1043
|
+
// (seen live, #326). It is a label, not a word of the thought: a space takes its place.
|
|
1044
|
+
.replace(/(?<=\S)\s*\bThought:\s*/g, " ")
|
|
1041
1045
|
.replace(/```[a-z]*\n?/gi, " ")
|
|
1042
1046
|
.replace(/\s+/g, " ")
|
|
1043
1047
|
.trim();
|
|
@@ -1735,7 +1739,12 @@ export function answerBlock({
|
|
|
1735
1739
|
doneAt = Date.now(),
|
|
1736
1740
|
inputLabel = "",
|
|
1737
1741
|
} = {}) {
|
|
1738
|
-
|
|
1742
|
+
// NO "recap:" LABEL (#326). It was added when the answer arrived under a long list of step
|
|
1743
|
+
// lines and needed saying what it was; the look-ups are one row now, so the answer is simply
|
|
1744
|
+
// the bright text under them — and a label on it read as if a recap FOLLOWED the answer.
|
|
1745
|
+
// `worked` is still accepted: callers pass it, and it is what a real recap line would gate on.
|
|
1746
|
+
void worked;
|
|
1747
|
+
const rendered = render(answer);
|
|
1739
1748
|
const clock = formatClockTime(doneAt);
|
|
1740
1749
|
// The stamp is DROPPED, not defaulted, when the time is unusable — "done Invalid Date" beside
|
|
1741
1750
|
// a correct duration is worse than no stamp at all. The same for the input figure (#53): the
|
|
@@ -2619,6 +2628,11 @@ export function formatThinkTime(ms) {
|
|
|
2619
2628
|
const _PICTOGRAPH =
|
|
2620
2629
|
/[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}️]+[ \t]?/gu;
|
|
2621
2630
|
|
|
2631
|
+
// A KEYCAP ("1️⃣") is a digit + a variation selector + U+20E3, and only the selector is a
|
|
2632
|
+
// pictograph: stripping left "1⃣ What it is" — a digit wearing a combining box, which terminals
|
|
2633
|
+
// draw badly or over the next letter (seen live, #326). The model meant a numbered point: "1.".
|
|
2634
|
+
const _KEYCAP = /([0-9#*])\uFE0F?\u20E3/g;
|
|
2635
|
+
|
|
2622
2636
|
/**
|
|
2623
2637
|
* The answer as PLAIN TEXT: decorative emoji removed (task #178).
|
|
2624
2638
|
*
|
|
@@ -2643,7 +2657,7 @@ export function plainAnswer(text) {
|
|
|
2643
2657
|
: // …and within prose, odd indices are inline code spans.
|
|
2644
2658
|
part
|
|
2645
2659
|
.split(/(`[^`\n]*`)/g)
|
|
2646
|
-
.map((seg, j) => (j % 2 ? seg : seg.replace(_PICTOGRAPH, "")))
|
|
2660
|
+
.map((seg, j) => (j % 2 ? seg : seg.replace(_KEYCAP, "$1.").replace(_PICTOGRAPH, "")))
|
|
2647
2661
|
.join("")
|
|
2648
2662
|
)
|
|
2649
2663
|
.join("")
|