@tiens.nguyen/gu-cli 1.0.740 → 1.0.741
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/gonext_agent_chat.py +44 -3
- package/gu-cli.mjs +23 -0
- package/gu-repl.mjs +62 -4
- package/gu_hooks.py +231 -0
- package/hooks-command.mjs +71 -0
- package/hooks-config.mjs +172 -0
- package/package.json +4 -1
- package/terminal-layout.mjs +48 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -7251,6 +7251,32 @@ def run_agent_chat(cfg):
|
|
|
7251
7251
|
+ (_sk_req or "(nothing more — do what the skill describes)"))
|
|
7252
7252
|
_log(f"skill invoked: {_sk['name']}")
|
|
7253
7253
|
|
|
7254
|
+
# HOOKS (task #270): onStart on the first turn of a gu session, onAsk on every question. What
|
|
7255
|
+
# they print goes in beside the task; exit 2 from onAsk stops the turn before any model call.
|
|
7256
|
+
import gu_hooks as _hooks_mod
|
|
7257
|
+
_hooks = _hooks_mod.from_cfg(cfg, _log)
|
|
7258
|
+
_hook_context = ""
|
|
7259
|
+
if _hooks:
|
|
7260
|
+
_hr_parts, _hr_warn = [], []
|
|
7261
|
+
if _hooks.has("onStart") and _hooks_mod.is_new_session(_hooks.cwd or "", _hooks.session_id):
|
|
7262
|
+
_r = _hooks.run("onStart", {"source": "startup"})
|
|
7263
|
+
_hr_parts.append(_r.text)
|
|
7264
|
+
_hr_warn += _r.warnings
|
|
7265
|
+
if _hooks.has("onAsk"):
|
|
7266
|
+
_r = _hooks.run("onAsk", {"question": latest_user_text})
|
|
7267
|
+
_hr_warn += _r.warnings
|
|
7268
|
+
if _r.blocked:
|
|
7269
|
+
_emit({"type": "final", "text": f"Stopped by your onAsk hook: {_r.blocked}"})
|
|
7270
|
+
return
|
|
7271
|
+
_hr_parts.append(_r.text)
|
|
7272
|
+
for _w in _hr_warn:
|
|
7273
|
+
_emit({"type": "step", "text": f"⚠ {_w}"})
|
|
7274
|
+
_hook_context = "\n\n".join(p for p in _hr_parts if p.strip())
|
|
7275
|
+
if _hook_context:
|
|
7276
|
+
_log(f"hooks added {len(_hook_context)} chars of context")
|
|
7277
|
+
task_text += ("\n\nCONTEXT FROM YOUR gu HOOKS (onStart/onAsk — background, not the "
|
|
7278
|
+
f"request itself):\n{_hook_context}")
|
|
7279
|
+
|
|
7254
7280
|
# Walk prior turns newest-first, keeping condensed lines until the budget is
|
|
7255
7281
|
# spent, then restore chronological (oldest→newest) order.
|
|
7256
7282
|
history_lines = []
|
|
@@ -8153,7 +8179,10 @@ def run_agent_chat(cfg):
|
|
|
8153
8179
|
# told to manage; a model that believes otherwise turns "own the environment" into
|
|
8154
8180
|
# "write the user instructions for owning it" — see _machine_context.
|
|
8155
8181
|
+ _machine_context(_sys_platform(), _sys_arch(), _WS_ACTIVE or "")
|
|
8156
|
-
|
|
8182
|
+
# "the task you were GIVEN": ownership is of the request, not of everything it mentions.
|
|
8183
|
+
# `create task "only keep logo on flash screen"` asks for a RECORD; read as "own the logo
|
|
8184
|
+
# change", the agent saved the task and then went on to do it (scaffold rule 8).
|
|
8185
|
+
+ "WORK LIKE A SENIOR DEVELOPER — you OWN the task you were GIVEN until it DEMONSTRABLY works:\n"
|
|
8157
8186
|
" * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
|
|
8158
8187
|
"state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
|
|
8159
8188
|
"(server responds, artifact exists, test green, file contains the change) before "
|
|
@@ -8409,8 +8438,9 @@ def run_agent_chat(cfg):
|
|
|
8409
8438
|
# Task #90: ~25-token echo of the autonomy contract at the highest-attention spot
|
|
8410
8439
|
# (recency — right next to the task), reinforcing the full directive that lives in
|
|
8411
8440
|
# the cached reference above. Workspace/coding mode only.
|
|
8412
|
-
+ ("Remember: FINISH
|
|
8413
|
-
"final_answer with evidence; never hand the task back
|
|
8441
|
+
+ ("Remember: FINISH what was asked yourself — fix blockers, VERIFY the outcome, then "
|
|
8442
|
+
"final_answer with evidence; never hand the task back. If it only asks to RECORD work "
|
|
8443
|
+
"(create a task, a note, a plan), recording it IS the task — do not start the work.\n"
|
|
8414
8444
|
if _WS_AVAILABLE else "")
|
|
8415
8445
|
+ "\nTASK (answer THIS, choose the tool that fits it):\n"
|
|
8416
8446
|
f"{task_text}\n"
|
|
@@ -12487,6 +12517,11 @@ def run_agent_chat(cfg):
|
|
|
12487
12517
|
for _note in _mcp_notes:
|
|
12488
12518
|
_emit({"type": "step", "text": _note})
|
|
12489
12519
|
agent_tools += _mcp_mod.build_tools(_mcp_conn, _SmolTool, _mcp_approve)
|
|
12520
|
+
# onToolDone (task #270): after every tool the hooks' matchers name — MCP tools included.
|
|
12521
|
+
if _hooks:
|
|
12522
|
+
_n_wrapped = _hooks_mod.wrap_tools(agent_tools, _hooks)
|
|
12523
|
+
if _n_wrapped:
|
|
12524
|
+
_log(f"onToolDone hooks on {_n_wrapped} tool(s)")
|
|
12490
12525
|
# Fill in the numbered tool list now that the REAL tool objects exist — see
|
|
12491
12526
|
# _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
|
|
12492
12527
|
task_with_hint = task_with_hint.replace(
|
|
@@ -12661,6 +12696,12 @@ def run_agent_chat(cfg):
|
|
|
12661
12696
|
final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
|
|
12662
12697
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
12663
12698
|
_emit({"type": "final", "text": final_text})
|
|
12699
|
+
# onTurnEnd (task #270): after the answer is out, so a slow hook never delays it.
|
|
12700
|
+
if _hooks and _hooks.has("onTurnEnd"):
|
|
12701
|
+
_te = _hooks.run("onTurnEnd", {"question": latest_user_text, "answer": final_text,
|
|
12702
|
+
"partial": bool(_maxsteps_partial.get("hit"))})
|
|
12703
|
+
for _w in _te.warnings:
|
|
12704
|
+
_log(f"onTurnEnd: {_w}")
|
|
12664
12705
|
# Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
|
|
12665
12706
|
# final_answer, or a deterministic PDF delivery) — that content is now in the
|
|
12666
12707
|
# answer pushed to conversation history, so the raw trace is no longer needed.
|
package/gu-cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import OpenAI from "openai";
|
|
|
35
35
|
import { resolvePython } from "./platform-tools.mjs";
|
|
36
36
|
import { skillsForTurn } from "./skills.mjs";
|
|
37
37
|
import { mcpForTurn } from "./mcp-config.mjs";
|
|
38
|
+
import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
|
|
38
39
|
|
|
39
40
|
/** The `skills` / `mcp` fields of one job's agent config (task #269). A broken config is a log
|
|
40
41
|
* line, never a failed turn — the turn runs without them. */
|
|
@@ -52,6 +53,18 @@ function skillsAndMcpForJob(cwd) {
|
|
|
52
53
|
} catch (e) {
|
|
53
54
|
console.log(`[gu-worker] MCP servers skipped: ${e?.message || e}`);
|
|
54
55
|
}
|
|
56
|
+
// Hooks (task #270): yours, plus the project's once approved; and the terminal session this
|
|
57
|
+
// turn belongs to, so the agent knows when onStart is due.
|
|
58
|
+
try {
|
|
59
|
+
const hooks = hooksForTurn({ cwd });
|
|
60
|
+
if (Object.keys(hooks).length) {
|
|
61
|
+
out.hooks = hooks;
|
|
62
|
+
const session = sessionForTurn(cwd);
|
|
63
|
+
if (session) out.hookSession = session;
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.log(`[gu-worker] hooks skipped: ${e?.message || e}`);
|
|
67
|
+
}
|
|
55
68
|
return out;
|
|
56
69
|
}
|
|
57
70
|
|
|
@@ -254,6 +267,16 @@ if (args[0] === "mcp") {
|
|
|
254
267
|
process.exit(code);
|
|
255
268
|
}
|
|
256
269
|
|
|
270
|
+
if (args[0] === "hooks") {
|
|
271
|
+
// #270. Hooks from a shell — same code as /hooks in the terminal UI.
|
|
272
|
+
const { runHooksCommand } = await import("./hooks-command.mjs");
|
|
273
|
+
const code = await runHooksCommand(args.slice(1), {
|
|
274
|
+
cwd: process.cwd(),
|
|
275
|
+
print: (line, kind) => (kind === "warn" ? console.error(line) : console.log(line)),
|
|
276
|
+
});
|
|
277
|
+
process.exit(code);
|
|
278
|
+
}
|
|
279
|
+
|
|
257
280
|
if (args[0] === "full-log") {
|
|
258
281
|
// #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
|
|
259
282
|
// token minted on the web admin page. Same code as /full-log in the terminal UI.
|
package/gu-repl.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { dirname, join, resolve, basename } from "node:path";
|
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
36
|
import { PassThrough } from "node:stream";
|
|
37
37
|
import { createInterface } from "node:readline";
|
|
38
|
-
import { createHash } from "node:crypto";
|
|
38
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
39
39
|
import { createRequire } from "node:module";
|
|
40
40
|
import dotenv from "dotenv";
|
|
41
41
|
// Pure layout arithmetic for every in-place block (bottom bar, live status, pickers) — kept
|
|
@@ -117,11 +117,14 @@ import {
|
|
|
117
117
|
identityRow,
|
|
118
118
|
CANCEL_LINE,
|
|
119
119
|
shouldCancelPrompt,
|
|
120
|
+
rankSlashCommands,
|
|
121
|
+
slashMenuWindow,
|
|
120
122
|
} from "./terminal-layout.mjs";
|
|
121
123
|
import { completedFlush } from "./completed-flush.mjs";
|
|
122
124
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
123
125
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
124
126
|
import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
127
|
+
import { readHooks } from "./hooks-config.mjs";
|
|
125
128
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
126
129
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
127
130
|
import {
|
|
@@ -535,7 +538,7 @@ const argv = process.argv.slice(2);
|
|
|
535
538
|
// process.argv and exits inside its own subcommand branch, before it ever starts polling.
|
|
536
539
|
const CLI_SUBCOMMANDS = [
|
|
537
540
|
"status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
|
|
538
|
-
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp",
|
|
541
|
+
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks",
|
|
539
542
|
];
|
|
540
543
|
// `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
|
|
541
544
|
// the list, so it fell through to "no subcommand given" and a full agent session opened in that
|
|
@@ -810,6 +813,7 @@ const COMMANDS = [
|
|
|
810
813
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
811
814
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
812
815
|
{ name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
|
|
816
|
+
{ name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
|
|
813
817
|
{ name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
|
|
814
818
|
{ name: "/help", desc: "list these commands" },
|
|
815
819
|
{ name: "/exit", aliases: ["/quit"], desc: "quit" },
|
|
@@ -1680,14 +1684,28 @@ function slashOverlaySeq(line, cursor, menu, cols) {
|
|
|
1680
1684
|
}
|
|
1681
1685
|
|
|
1682
1686
|
// Redraw the input line AND the command menu below it, fully in place.
|
|
1687
|
+
// A short WINDOW over the ranked matches, not the whole registry (task #271): a bare "/" used to
|
|
1688
|
+
// draw every command and skill at once. ↑/↓ move through ALL matches and the window follows.
|
|
1689
|
+
let slashTop = 0;
|
|
1690
|
+
function slashMenuRows(cmds, sel, win, line) {
|
|
1691
|
+
if (!cmds.length) {
|
|
1692
|
+
return [dim(` no command matches ${line.trim().split(/\s+/)[0]} — /help lists them all`)];
|
|
1693
|
+
}
|
|
1694
|
+
const rows = hintRows(cmds.slice(win.from, win.to), sel - win.from);
|
|
1695
|
+
if (win.above) rows.splice(1, 0, dim(` ⋯ ${win.above} more above`));
|
|
1696
|
+
if (win.below) rows.push(dim(` ⋯ ${win.below} more — keep typing to narrow`));
|
|
1697
|
+
return rows;
|
|
1698
|
+
}
|
|
1683
1699
|
function renderSlashOverlay(sel) {
|
|
1684
1700
|
const line = rl.line;
|
|
1685
|
-
const cmds =
|
|
1701
|
+
const cmds = rankSlashCommands(allCommands(), line);
|
|
1686
1702
|
slashNames = cmds.map((c) => c.name);
|
|
1687
1703
|
if (sel >= cmds.length) sel = cmds.length - 1;
|
|
1688
1704
|
slashSel = sel;
|
|
1689
1705
|
slashPrefix = line;
|
|
1690
|
-
const
|
|
1706
|
+
const win = slashMenuWindow(cmds.length, sel, slashTop);
|
|
1707
|
+
slashTop = win.top;
|
|
1708
|
+
const menu = slashMenuRows(cmds, sel, win, line);
|
|
1691
1709
|
if (!slashOverlayActive) {
|
|
1692
1710
|
slashOverlayActive = true;
|
|
1693
1711
|
slashNeutralizeHistory();
|
|
@@ -1712,6 +1730,7 @@ function closeSlashOverlay() {
|
|
|
1712
1730
|
slashOverlayActive = false;
|
|
1713
1731
|
slashHintRows = 0;
|
|
1714
1732
|
slashSel = -1;
|
|
1733
|
+
slashTop = 0;
|
|
1715
1734
|
slashNames = [];
|
|
1716
1735
|
slashRestoreHistory();
|
|
1717
1736
|
}
|
|
@@ -4436,6 +4455,23 @@ async function loadSessionUnfinishedTask(cwd) {
|
|
|
4436
4455
|
* `mutate` receives the parsed object and may return a replacement or mutate in place.
|
|
4437
4456
|
*/
|
|
4438
4457
|
let _sessionWrite = Promise.resolve();
|
|
4458
|
+
/**
|
|
4459
|
+
* Mark the start of a gu session in this folder's session file (task #270). The worker reads the
|
|
4460
|
+
* id back (hooks-config.mjs sessionForTurn) and the agent runs onStart hooks on the first turn it
|
|
4461
|
+
* sees it — which is how "a session started" reaches the agent with nothing added to the API.
|
|
4462
|
+
*/
|
|
4463
|
+
async function startHookSession(cwd) {
|
|
4464
|
+
try {
|
|
4465
|
+
await updateSessionFile(cwd, (s) => {
|
|
4466
|
+
s.hookSessionId = randomUUID();
|
|
4467
|
+
s.hookSessionStartedAt = new Date().toISOString();
|
|
4468
|
+
return s;
|
|
4469
|
+
});
|
|
4470
|
+
} catch {
|
|
4471
|
+
/* no session file = no onStart this time, never a failed start */
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4439
4475
|
async function updateSessionFile(cwd, mutate) {
|
|
4440
4476
|
const run = async () => {
|
|
4441
4477
|
let raw = {};
|
|
@@ -10756,6 +10792,15 @@ async function main() {
|
|
|
10756
10792
|
for (const w of discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).warnings) {
|
|
10757
10793
|
console.log(GUTTER + yellow(w));
|
|
10758
10794
|
}
|
|
10795
|
+
// Hooks (task #270): a Claude Code event name, a broken file, or project hooks waiting.
|
|
10796
|
+
const hk = readHooks({ cwd: process.cwd() });
|
|
10797
|
+
for (const w of hk.warnings) console.log(GUTTER + yellow(w));
|
|
10798
|
+
if (hk.projectPending) {
|
|
10799
|
+
console.log(
|
|
10800
|
+
GUTTER + yellow("This folder's .gonext/settings.json has hooks that will not run until you approve them.") +
|
|
10801
|
+
"\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
|
|
10802
|
+
);
|
|
10803
|
+
}
|
|
10759
10804
|
const waiting = unapprovedProjectServers(process.cwd());
|
|
10760
10805
|
if (waiting.length) {
|
|
10761
10806
|
console.log(
|
|
@@ -11065,6 +11110,8 @@ async function main() {
|
|
|
11065
11110
|
|
|
11066
11111
|
const cwd = resolve(process.cwd());
|
|
11067
11112
|
const history = await loadSession(cwd);
|
|
11113
|
+
// A new gu session (task #270): the agent runs onStart hooks on the first turn it sees this id.
|
|
11114
|
+
await startHookSession(cwd);
|
|
11068
11115
|
if (history.length > 0) {
|
|
11069
11116
|
const turns = history.filter((m) => m.role === "user").length;
|
|
11070
11117
|
console.log(
|
|
@@ -11323,6 +11370,16 @@ async function main() {
|
|
|
11323
11370
|
await runMcpSlashCommand(line);
|
|
11324
11371
|
continue;
|
|
11325
11372
|
}
|
|
11373
|
+
if (line === "/hooks" || line.startsWith("/hooks ")) {
|
|
11374
|
+
const { runHooksCommand } = await import("./hooks-command.mjs");
|
|
11375
|
+
const style = { ok: accent, warn: yellow, dim };
|
|
11376
|
+
await runHooksCommand(line.split(/\s+/).slice(1), {
|
|
11377
|
+
cwd: process.cwd(),
|
|
11378
|
+
print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
|
|
11379
|
+
});
|
|
11380
|
+
console.log("");
|
|
11381
|
+
continue;
|
|
11382
|
+
}
|
|
11326
11383
|
if (line === "/proxy" || line.startsWith("/proxy ")) {
|
|
11327
11384
|
await runProxyCommand(line);
|
|
11328
11385
|
continue;
|
|
@@ -11415,6 +11472,7 @@ async function main() {
|
|
|
11415
11472
|
if (line === "/reset" || line === "/new") {
|
|
11416
11473
|
history.length = 0;
|
|
11417
11474
|
await clearSession(cwd);
|
|
11475
|
+
await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
|
|
11418
11476
|
console.log(GUTTER + dim("Session cleared — starting fresh.\n"));
|
|
11419
11477
|
continue;
|
|
11420
11478
|
}
|
package/gu_hooks.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Hooks for the gu agent (task #270): commands gu runs at fixed moments of a turn.
|
|
2
|
+
|
|
3
|
+
onStart the first turn of a gu session (gu started, or /reset)
|
|
4
|
+
onAsk every question, before the agent starts on it
|
|
5
|
+
onToolDone after a tool the matcher names (gu's tool names: edit_file, grep_repo, mcp__…)
|
|
6
|
+
onTurnEnd after the answer has been sent
|
|
7
|
+
|
|
8
|
+
gu's own names and gu's own tool names, deliberately not Claude Code's. The worker hands the agent
|
|
9
|
+
the hooks that apply (hooks-config.mjs: yours, plus a project's only once approved) and the
|
|
10
|
+
session this turn belongs to.
|
|
11
|
+
|
|
12
|
+
A HOOK IS A COMMAND. It gets one JSON object on stdin and may answer on stdout:
|
|
13
|
+
{"additionalContext": "..."} added for the model (onStart, onAsk; onToolDone: after the result)
|
|
14
|
+
plain text the same as additionalContext
|
|
15
|
+
exit code 2 stop: onAsk stops the turn, onToolDone tells the model; stderr is why
|
|
16
|
+
A hook that fails any other way, or runs past its timeout, is a warning — never a failed turn.
|
|
17
|
+
|
|
18
|
+
WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
|
|
19
|
+
credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
EVENTS = ("onStart", "onAsk", "onToolDone", "onTurnEnd")
|
|
31
|
+
DEFAULT_TIMEOUT_S = 30
|
|
32
|
+
CONTEXT_CAP_CHARS = 6000
|
|
33
|
+
_ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", "SHELL", "USER",
|
|
34
|
+
"USERNAME", "LOGNAME", "TERM", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "USERPROFILE",
|
|
35
|
+
"PROGRAMDATA", "PATHEXT", "COMSPEC", "VIRTUAL_ENV", "PYTHONIOENCODING")
|
|
36
|
+
_PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def matches(matcher: str | None, value: str) -> bool:
|
|
40
|
+
"""Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
|
|
41
|
+
m = (matcher or "").strip()
|
|
42
|
+
if m in ("", "*"):
|
|
43
|
+
return True
|
|
44
|
+
if _PLAIN_MATCHER.match(m):
|
|
45
|
+
return value in {x.strip() for x in re.split(r"[|,]", m) if x.strip()}
|
|
46
|
+
try:
|
|
47
|
+
return re.search(m, value) is not None
|
|
48
|
+
except re.error:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HookResult:
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.context: list[str] = []
|
|
55
|
+
self.blocked: str | None = None
|
|
56
|
+
self.warnings: list[str] = []
|
|
57
|
+
self.ran = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def text(self) -> str:
|
|
61
|
+
t = "\n\n".join(c for c in self.context if c.strip())
|
|
62
|
+
return t[:CONTEXT_CAP_CHARS] + ("\n… [hook output cut]" if len(t) > CONTEXT_CAP_CHARS else "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HookRunner:
|
|
66
|
+
def __init__(self, hooks: dict, cwd: str | None, session_id: str,
|
|
67
|
+
log: Callable[[str], None] = lambda s: None):
|
|
68
|
+
self.hooks = hooks if isinstance(hooks, dict) else {}
|
|
69
|
+
self.cwd = cwd if cwd and os.path.isdir(cwd) else None
|
|
70
|
+
self.session_id = session_id or "unknown"
|
|
71
|
+
self.log = log
|
|
72
|
+
|
|
73
|
+
def has(self, event: str) -> bool:
|
|
74
|
+
return bool(self.hooks.get(event))
|
|
75
|
+
|
|
76
|
+
def matcher_union(self, event: str) -> list[str]:
|
|
77
|
+
return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
|
|
78
|
+
|
|
79
|
+
def _env(self, event: str, extra: dict | None) -> dict:
|
|
80
|
+
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
|
|
81
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
82
|
+
env["GU_PROJECT_DIR"] = self.cwd or ""
|
|
83
|
+
env["GU_HOOK_EVENT"] = event
|
|
84
|
+
for k, v in (extra or {}).items():
|
|
85
|
+
env[str(k)] = str(v)
|
|
86
|
+
return env
|
|
87
|
+
|
|
88
|
+
def run(self, event: str, payload: dict, match_value: str = "") -> HookResult:
|
|
89
|
+
res = HookResult()
|
|
90
|
+
if event not in EVENTS:
|
|
91
|
+
return res
|
|
92
|
+
body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
|
|
93
|
+
data = json.dumps(body, ensure_ascii=False, default=str)
|
|
94
|
+
for group in self.hooks.get(event) or []:
|
|
95
|
+
if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
|
|
96
|
+
continue
|
|
97
|
+
for h in group.get("hooks") or []:
|
|
98
|
+
if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
|
|
99
|
+
continue
|
|
100
|
+
self._one(event, h, data, res)
|
|
101
|
+
return res
|
|
102
|
+
|
|
103
|
+
def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
|
|
104
|
+
cmd = str(h["command"])
|
|
105
|
+
args = [str(a) for a in (h.get("args") or [])]
|
|
106
|
+
timeout = float(h.get("timeout") or DEFAULT_TIMEOUT_S)
|
|
107
|
+
label = os.path.basename(args[-1] if args else cmd.split()[0])
|
|
108
|
+
t0 = time.time()
|
|
109
|
+
res.ran += 1
|
|
110
|
+
try:
|
|
111
|
+
p = subprocess.run([cmd, *args] if args else cmd, shell=not args, input=data,
|
|
112
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
113
|
+
timeout=timeout, cwd=self.cwd, env=self._env(event, h.get("env")))
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
res.warnings.append(f"{event} hook {label} took longer than {timeout:.0f}s and was stopped")
|
|
116
|
+
self.log(f"hook {event} {label}: timeout {timeout:.0f}s")
|
|
117
|
+
return
|
|
118
|
+
except OSError as e:
|
|
119
|
+
res.warnings.append(f"{event} hook {label} could not start: {e.strerror or e}")
|
|
120
|
+
self.log(f"hook {event} {label}: could not start")
|
|
121
|
+
return
|
|
122
|
+
ms = int((time.time() - t0) * 1000)
|
|
123
|
+
out = (p.stdout or "").strip()
|
|
124
|
+
err = (p.stderr or "").strip()
|
|
125
|
+
# Sizes only: a hook's output is conversation content, and the log is content-free (#249).
|
|
126
|
+
self.log(f"hook {event} {label}: exit {p.returncode} in {ms}ms, {len(out)} chars out")
|
|
127
|
+
if p.returncode == 2:
|
|
128
|
+
res.blocked = err or f"the {event} hook {label} said no"
|
|
129
|
+
return
|
|
130
|
+
if p.returncode != 0:
|
|
131
|
+
tail = err.splitlines()[-1][:200] if err else ""
|
|
132
|
+
res.warnings.append(f"{event} hook {label} failed (exit {p.returncode})" + (f": {tail}" if tail else ""))
|
|
133
|
+
return
|
|
134
|
+
if not out:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
obj = json.loads(out)
|
|
138
|
+
except ValueError:
|
|
139
|
+
res.context.append(out) # plain text = context
|
|
140
|
+
return
|
|
141
|
+
if isinstance(obj, dict):
|
|
142
|
+
if str(obj.get("decision", "")).lower() == "block":
|
|
143
|
+
res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
|
|
144
|
+
return
|
|
145
|
+
ctx = obj.get("additionalContext")
|
|
146
|
+
if isinstance(ctx, str) and ctx.strip():
|
|
147
|
+
res.context.append(ctx)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- onStart: once per session ---------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _state_path() -> str:
|
|
153
|
+
return os.path.join(os.path.expanduser("~"), ".gonext", "hooks-state.json")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_new_session(cwd: str, session_id: str, path: str | None = None) -> bool:
|
|
157
|
+
"""True the first time this session is seen for this folder — and records it."""
|
|
158
|
+
path = path or _state_path()
|
|
159
|
+
key = os.path.realpath(cwd or "")
|
|
160
|
+
try:
|
|
161
|
+
with open(path, encoding="utf-8") as fh:
|
|
162
|
+
state = json.load(fh)
|
|
163
|
+
if not isinstance(state, dict):
|
|
164
|
+
state = {}
|
|
165
|
+
except (OSError, ValueError):
|
|
166
|
+
state = {}
|
|
167
|
+
if state.get(key) == session_id:
|
|
168
|
+
return False
|
|
169
|
+
state[key] = session_id
|
|
170
|
+
try:
|
|
171
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
172
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
173
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
174
|
+
json.dump(state, fh, indent=2)
|
|
175
|
+
os.replace(tmp, path)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---- onToolDone: wrapping the agent's tools ---------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
|
|
184
|
+
"""Run onToolDone after every tool a matcher names. Returns how many tools were wrapped. Hook
|
|
185
|
+
context is appended to the tool's result; a block (exit 2) is appended as a message."""
|
|
186
|
+
if not runner.has("onToolDone"):
|
|
187
|
+
return 0
|
|
188
|
+
matchers = runner.matcher_union("onToolDone")
|
|
189
|
+
n = 0
|
|
190
|
+
for t in tools:
|
|
191
|
+
name = getattr(t, "name", "")
|
|
192
|
+
if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
|
|
193
|
+
continue
|
|
194
|
+
orig = t.forward
|
|
195
|
+
order = list((getattr(t, "inputs", None) or {}).keys())
|
|
196
|
+
|
|
197
|
+
def forward(*pos, _orig=orig, _name=name, _order=order, **kw):
|
|
198
|
+
result = _orig(*pos, **kw)
|
|
199
|
+
args = dict(kw)
|
|
200
|
+
for k, v in zip(_order, pos):
|
|
201
|
+
args.setdefault(k, v)
|
|
202
|
+
text = str(result)
|
|
203
|
+
r = runner.run("onToolDone", {"tool_name": _name, "tool_input": args,
|
|
204
|
+
"tool_result": text[:clip]}, match_value=_name)
|
|
205
|
+
extra = []
|
|
206
|
+
if r.blocked:
|
|
207
|
+
extra.append(f"[onToolDone hook] {r.blocked}")
|
|
208
|
+
if r.text:
|
|
209
|
+
extra.append(f"[onToolDone hook]\n{r.text}")
|
|
210
|
+
return f"{result}\n\n" + "\n".join(extra) if extra and isinstance(result, str) else result
|
|
211
|
+
|
|
212
|
+
t.forward = forward
|
|
213
|
+
n += 1
|
|
214
|
+
return n
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def from_cfg(cfg: dict, log: Callable[[str], None]) -> HookRunner | None:
|
|
218
|
+
hooks = cfg.get("hooks")
|
|
219
|
+
if not isinstance(hooks, dict) or not any(hooks.get(e) for e in EVENTS):
|
|
220
|
+
return None
|
|
221
|
+
sess = cfg.get("hookSession") if isinstance(cfg.get("hookSession"), dict) else {}
|
|
222
|
+
cwd = (cfg.get("activeWorkspace") or "").strip() or None
|
|
223
|
+
if sess.get("id"):
|
|
224
|
+
return HookRunner(hooks, cwd, str(sess["id"]), log)
|
|
225
|
+
# No terminal session (the web, or a terminal older than hooks). The job id would be a NEW
|
|
226
|
+
# "session" every turn — onStart on every question — so onStart is skipped, and the other
|
|
227
|
+
# events share one id per day, which keeps the memory hook's steps together.
|
|
228
|
+
import datetime
|
|
229
|
+
runner = HookRunner(hooks, cwd, "gu-" + datetime.date.today().isoformat(), log)
|
|
230
|
+
runner.hooks = {k: v for k, v in runner.hooks.items() if k != "onStart"}
|
|
231
|
+
return runner
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gu hooks …` and `/hooks …` — ONE code path (task #270).
|
|
3
|
+
*
|
|
4
|
+
* list every hook for this folder: yours, and the project's with whether it is approved
|
|
5
|
+
* approve run this project's hooks (as they are now — an edit needs approving again)
|
|
6
|
+
* unapprove stop running them
|
|
7
|
+
*/
|
|
8
|
+
import { HOOK_EVENTS, describeHook, readHooks, approveProjectHooks, userSettingsPath } from "./hooks-config.mjs";
|
|
9
|
+
|
|
10
|
+
export const HOOKS_USAGE = "gu hooks [list|approve|unapprove]";
|
|
11
|
+
|
|
12
|
+
function printGroups(hooks, print, indent = " ") {
|
|
13
|
+
for (const ev of HOOK_EVENTS) {
|
|
14
|
+
for (const g of hooks[ev] ?? []) {
|
|
15
|
+
const on = g.matcher ? ` (tools: ${g.matcher})` : "";
|
|
16
|
+
for (const h of g.hooks) print(`${indent}${ev.padEnd(11)} ${describeHook(h)}${on}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runHooksCommand(words, { cwd = process.cwd(), print = console.log } = {}) {
|
|
22
|
+
const sub = String((words ?? [])[0] ?? "list").toLowerCase();
|
|
23
|
+
try {
|
|
24
|
+
if (sub === "list" || sub === "ls") {
|
|
25
|
+
const r = readHooks({ cwd });
|
|
26
|
+
for (const w of r.warnings) print(w, "warn");
|
|
27
|
+
const nUser = Object.keys(r.user).length;
|
|
28
|
+
const nProj = Object.keys(r.project).length;
|
|
29
|
+
if (!nUser && !nProj) {
|
|
30
|
+
print("No hooks.");
|
|
31
|
+
print(`Add them under "hooks" in ${userSettingsPath()} — events: ${HOOK_EVENTS.join(", ")}.`, "dim");
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (nUser) {
|
|
35
|
+
print(`Yours (${userSettingsPath()}):`, "ok");
|
|
36
|
+
printGroups(r.user, (l) => print(l, "dim"));
|
|
37
|
+
}
|
|
38
|
+
if (nProj) {
|
|
39
|
+
print(
|
|
40
|
+
r.projectApproved
|
|
41
|
+
? "This project's (./.gonext/settings.json) — approved:"
|
|
42
|
+
: "This project's (./.gonext/settings.json) — NOT running until you approve them:",
|
|
43
|
+
r.projectApproved ? "ok" : "warn"
|
|
44
|
+
);
|
|
45
|
+
printGroups(r.project, (l) => print(l, "dim"));
|
|
46
|
+
if (!r.projectApproved) print("Check the commands above, then: /hooks approve", "dim");
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (sub === "approve" || sub === "unapprove") {
|
|
51
|
+
const r = readHooks({ cwd });
|
|
52
|
+
if (!Object.keys(r.project).length) {
|
|
53
|
+
print("This folder has no hooks of its own (./.gonext/settings.json).", "warn");
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
approveProjectHooks({ cwd, approve: sub === "approve" });
|
|
57
|
+
print(
|
|
58
|
+
sub === "approve"
|
|
59
|
+
? "Approved: this project's hooks run from the next turn. Editing them needs approving again."
|
|
60
|
+
: "This project's hooks will not run here.",
|
|
61
|
+
"ok"
|
|
62
|
+
);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
print(`unknown "${sub}" — ${HOOKS_USAGE}`, "warn");
|
|
66
|
+
return 2;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
print(e?.message || String(e), "warn");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/hooks-config.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOOKS — commands gu runs at fixed moments of a turn (task #270). Where they are set, and which
|
|
3
|
+
* ones a turn gets.
|
|
4
|
+
*
|
|
5
|
+
* ~/.gonext/settings.json yours, every folder
|
|
6
|
+
* <project>/.gonext/settings.json this project's — run only once you approve them here
|
|
7
|
+
*
|
|
8
|
+
* { "hooks": { "<event>": [ { "matcher": "edit_file|create_file",
|
|
9
|
+
* "hooks": [ { "type": "command", "command": "python3",
|
|
10
|
+
* "args": ["/path/hook.py"], "timeout": 30 } ] } ] } }
|
|
11
|
+
*
|
|
12
|
+
* gu's OWN event names — onStart, onAsk, onToolDone, onTurnEnd — and gu's own tool names in
|
|
13
|
+
* matchers (edit_file, grep_repo, mcp__<server>__<tool>). Claude Code's names (SessionStart, …) are
|
|
14
|
+
* recognised only to say what to write instead: a hook filed under a name gu never fires would
|
|
15
|
+
* otherwise fail in silence.
|
|
16
|
+
*
|
|
17
|
+
* A PROJECT'S HOOKS ARE COMMANDS THAT CAME WITH THE REPOSITORY, so they never run until you approve
|
|
18
|
+
* them for that folder — and the approval is of their exact content (a hash): an edited hooks
|
|
19
|
+
* block needs approving again. The approval lives in YOUR settings file, never the project's.
|
|
20
|
+
*
|
|
21
|
+
* THE SESSION. onStart fires on the first turn of a gu session. The terminal writes a new session
|
|
22
|
+
* id into the folder's session file when it starts (and on /reset); sessionForTurn reads it back so
|
|
23
|
+
* the agent can tell "new session" from "next turn" with nothing added to the API.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
export const HOOK_EVENTS = ["onStart", "onAsk", "onToolDone", "onTurnEnd"];
|
|
31
|
+
/** Claude Code's names → gu's. Only used to tell the user what to rename. */
|
|
32
|
+
export const CLAUDE_EVENT_NAMES = {
|
|
33
|
+
SessionStart: "onStart",
|
|
34
|
+
UserPromptSubmit: "onAsk",
|
|
35
|
+
PostToolUse: "onToolDone",
|
|
36
|
+
Stop: "onTurnEnd",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const userSettingsPath = (home = homedir()) => join(home, ".gonext", "settings.json");
|
|
40
|
+
export const projectSettingsPath = (cwd) => join(cwd, ".gonext", "settings.json");
|
|
41
|
+
|
|
42
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
43
|
+
|
|
44
|
+
function readJson(file) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return { data: null };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(text);
|
|
53
|
+
return isObj(data) ? { data } : { data: null, error: "not a JSON object" };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { data: null, error: `not valid JSON (${e.message})` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A settings file's hooks, cleaned: only gu events, only command hooks. → { hooks, warnings } */
|
|
60
|
+
export function cleanHooks(raw, where) {
|
|
61
|
+
const hooks = {};
|
|
62
|
+
const warnings = [];
|
|
63
|
+
if (!isObj(raw)) return { hooks, warnings };
|
|
64
|
+
for (const [event, groups] of Object.entries(raw)) {
|
|
65
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
66
|
+
const gu = CLAUDE_EVENT_NAMES[event];
|
|
67
|
+
warnings.push(
|
|
68
|
+
gu
|
|
69
|
+
? `${where}: "${event}" is Claude Code's name — gu calls it "${gu}". Skipped until renamed.`
|
|
70
|
+
: `${where}: "${event}" is not a gu hook event (${HOOK_EVENTS.join(", ")}). Skipped.`
|
|
71
|
+
);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const kept = [];
|
|
75
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
76
|
+
if (!isObj(g)) continue;
|
|
77
|
+
const hs = (Array.isArray(g.hooks) ? g.hooks : [])
|
|
78
|
+
.filter((h) => isObj(h) && (h.type ?? "command") === "command" && String(h.command ?? "").trim())
|
|
79
|
+
.map((h) => ({
|
|
80
|
+
type: "command",
|
|
81
|
+
command: String(h.command),
|
|
82
|
+
...(Array.isArray(h.args) ? { args: h.args.map(String) } : {}),
|
|
83
|
+
...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
|
|
84
|
+
...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
|
|
85
|
+
}));
|
|
86
|
+
if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs });
|
|
87
|
+
}
|
|
88
|
+
if (kept.length) hooks[event] = kept;
|
|
89
|
+
}
|
|
90
|
+
return { hooks, warnings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The exact content being approved — any edit changes it. */
|
|
94
|
+
export function hooksFingerprint(hooks) {
|
|
95
|
+
return createHash("sha256").update(JSON.stringify(hooks ?? {})).digest("hex").slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** → { user, project, projectApproved, projectPending, warnings } */
|
|
99
|
+
export function readHooks({ cwd, home = homedir() } = {}) {
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const u = readJson(userSettingsPath(home));
|
|
102
|
+
if (u.error) warnings.push(`${userSettingsPath(home)} is ${u.error} — its hooks are skipped`);
|
|
103
|
+
const user = cleanHooks(u.data?.hooks, "~/.gonext/settings.json");
|
|
104
|
+
warnings.push(...user.warnings);
|
|
105
|
+
let project = { hooks: {}, warnings: [] };
|
|
106
|
+
let projectApproved = false;
|
|
107
|
+
if (cwd && resolve(projectSettingsPath(cwd)) !== resolve(userSettingsPath(home))) {
|
|
108
|
+
const p = readJson(projectSettingsPath(cwd));
|
|
109
|
+
if (p.error) warnings.push(`${projectSettingsPath(cwd)} is ${p.error} — its hooks are skipped`);
|
|
110
|
+
project = cleanHooks(p.data?.hooks, ".gonext/settings.json");
|
|
111
|
+
warnings.push(...project.warnings);
|
|
112
|
+
const approved = u.data?.approvedProjectHooks?.[resolve(cwd)];
|
|
113
|
+
projectApproved = Object.keys(project.hooks).length > 0 && approved === hooksFingerprint(project.hooks);
|
|
114
|
+
}
|
|
115
|
+
const hasProject = Object.keys(project.hooks).length > 0;
|
|
116
|
+
return {
|
|
117
|
+
user: user.hooks,
|
|
118
|
+
project: project.hooks,
|
|
119
|
+
projectApproved,
|
|
120
|
+
projectPending: hasProject && !projectApproved,
|
|
121
|
+
warnings,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 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 });
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const ev of HOOK_EVENTS) {
|
|
130
|
+
const groups = [...(r.user[ev] ?? []), ...(r.projectApproved ? r.project[ev] ?? [] : [])];
|
|
131
|
+
if (groups.length) out[ev] = groups;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 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);
|
|
140
|
+
const { data, error } = readJson(file);
|
|
141
|
+
if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
|
|
142
|
+
const raw = data ?? {};
|
|
143
|
+
raw.approvedProjectHooks = isObj(raw.approvedProjectHooks) ? raw.approvedProjectHooks : {};
|
|
144
|
+
if (approve) raw.approvedProjectHooks[resolve(cwd)] = hooksFingerprint(r.project);
|
|
145
|
+
else delete raw.approvedProjectHooks[resolve(cwd)];
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
147
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
148
|
+
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
149
|
+
renameSync(tmp, file);
|
|
150
|
+
return r.project;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---------------------------------------------------------------- the session ------------ */
|
|
154
|
+
|
|
155
|
+
/** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
|
|
156
|
+
export function sessionFileFor(cwd, home = homedir()) {
|
|
157
|
+
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
|
|
158
|
+
return join(home, ".gonext", "sessions", `${hash}.json`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** → { id, startedAt } | null. null = no terminal session (the web, or an older terminal). */
|
|
162
|
+
export function sessionForTurn(cwd, home = homedir()) {
|
|
163
|
+
if (!cwd) return null;
|
|
164
|
+
const { data } = readJson(sessionFileFor(cwd, home));
|
|
165
|
+
const id = data?.hookSessionId;
|
|
166
|
+
return typeof id === "string" && id ? { id, startedAt: String(data.hookSessionStartedAt ?? "") } : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Describe one hook for a list: the command and its args, as one line. */
|
|
170
|
+
export function describeHook(h) {
|
|
171
|
+
return [h.command, ...(h.args ?? [])].map((w) => (/\s/.test(w) ? JSON.stringify(w) : w)).join(" ");
|
|
172
|
+
}
|
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.741",
|
|
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",
|
|
@@ -92,10 +92,13 @@
|
|
|
92
92
|
"skills.mjs",
|
|
93
93
|
"mcp-config.mjs",
|
|
94
94
|
"mcp-command.mjs",
|
|
95
|
+
"hooks-config.mjs",
|
|
96
|
+
"hooks-command.mjs",
|
|
95
97
|
"gonext_probe_agent.py",
|
|
96
98
|
"gonext_agent_chat.py",
|
|
97
99
|
"gu_mcp.py",
|
|
98
100
|
"gu_skills.py",
|
|
101
|
+
"gu_hooks.py",
|
|
99
102
|
"prompt_scaffold_check.py",
|
|
100
103
|
"gonext_transcribe.py",
|
|
101
104
|
"gonext_mlx_embed.py",
|
package/terminal-layout.mjs
CHANGED
|
@@ -2653,3 +2653,51 @@ export function shouldCancelPrompt(awaiting, key) {
|
|
|
2653
2653
|
if (key.ctrl) return false;
|
|
2654
2654
|
return key.sequence === "\u001b";
|
|
2655
2655
|
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* THE SLASH MENU SHOWS A FEW, AND NARROWS AS YOU TYPE (task #271), the way Claude Code's does.
|
|
2659
|
+
*
|
|
2660
|
+
* A bare "/" used to print every command at once: sixteen built-ins plus every skill, most of them
|
|
2661
|
+
* settings nobody touches twice, so the one you wanted was somewhere in a wall of text. Now the
|
|
2662
|
+
* menu is a short window (SLASH_MENU_ROWS) over the ranked matches, ↑/↓ scroll it, and each typed
|
|
2663
|
+
* character narrows it. /help still prints everything.
|
|
2664
|
+
*
|
|
2665
|
+
* MATCHING, best first, so the command whose name you are typing is always on top:
|
|
2666
|
+
* 0 the name starts with it /mo → /model
|
|
2667
|
+
* 1 a word of the name starts with it /model → then /agent-model, /embed-model
|
|
2668
|
+
* 2 the name contains it /del → /model …
|
|
2669
|
+
* 3 the description contains it /coding → /model ("switch the CODING model"); 3+ chars
|
|
2670
|
+
* Only the first word counts: "/proxy set" is /proxy with an argument. No match → [] (the menu
|
|
2671
|
+
* says so), never "everything", which is what it used to fall back to.
|
|
2672
|
+
*/
|
|
2673
|
+
export const SLASH_MENU_ROWS = 6;
|
|
2674
|
+
|
|
2675
|
+
export function rankSlashCommands(cmds, typed) {
|
|
2676
|
+
const list = cmds ?? [];
|
|
2677
|
+
const word = String(typed ?? "").trim().split(/\s+/)[0].toLowerCase().replace(/^\//, "");
|
|
2678
|
+
if (!word) return list.slice();
|
|
2679
|
+
const scored = [];
|
|
2680
|
+
list.forEach((c, i) => {
|
|
2681
|
+
const names = [c.name, ...(c.aliases ?? [])].map((n) => String(n).toLowerCase().replace(/^\//, ""));
|
|
2682
|
+
let score = -1;
|
|
2683
|
+
if (names.some((n) => n.startsWith(word))) score = 0;
|
|
2684
|
+
else if (names.some((n) => n.split(/[-_]/).some((part) => part.startsWith(word)))) score = 1;
|
|
2685
|
+
else if (names.some((n) => n.includes(word))) score = 2;
|
|
2686
|
+
else if (word.length >= 3 && String(c.desc ?? "").toLowerCase().includes(word)) score = 3;
|
|
2687
|
+
if (score >= 0) scored.push({ c, score, i });
|
|
2688
|
+
});
|
|
2689
|
+
scored.sort((a, b) => a.score - b.score || a.i - b.i);
|
|
2690
|
+
return scored.map((s) => s.c);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/**
|
|
2694
|
+
* Which slice of the ranked list is on screen. `sel` -1 = nothing highlighted (window at the top).
|
|
2695
|
+
* → { from, to, above, below, top } — above/below are how many are hidden on each side.
|
|
2696
|
+
*/
|
|
2697
|
+
export function slashMenuWindow(total, sel, top, rows = SLASH_MENU_ROWS) {
|
|
2698
|
+
const n = Math.max(0, total);
|
|
2699
|
+
if (n <= rows) return { from: 0, to: n, above: 0, below: 0, top: 0 };
|
|
2700
|
+
// listWindow spends two of its rows on the indicators; give it room for exactly `rows` items.
|
|
2701
|
+
const w = listWindow(n, sel < 0 ? 0 : sel, sel < 0 ? 0 : top, rows + 2);
|
|
2702
|
+
return { from: w.from, to: w.to, above: w.above, below: w.below, top: w.top };
|
|
2703
|
+
}
|