@tiens.nguyen/gu-cli 1.0.739 → 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 +126 -3
- package/gu-cli.mjs +58 -0
- package/gu-repl.mjs +173 -9
- package/gu_hooks.py +231 -0
- package/gu_mcp.py +486 -0
- package/gu_skills.py +93 -0
- package/hooks-command.mjs +71 -0
- package/hooks-config.mjs +172 -0
- package/mcp-command.mjs +324 -0
- package/mcp-config.mjs +329 -0
- package/package.json +9 -1
- package/skills.mjs +175 -0
- package/terminal-layout.mjs +48 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -4764,6 +4764,9 @@ _RAG_SKIP_DIRS = {
|
|
|
4764
4764
|
# .github, .vscode or .claude — those carry CI config, editor settings and project
|
|
4765
4765
|
# instructions a coding agent legitimately benefits from reading.
|
|
4766
4766
|
".claude-memory", ".specstory", ".aider.tags.cache.v3", ".goose",
|
|
4767
|
+
# gu's OWN memory store (the memory skill's .gu-memory/, task #269): the agent reaches it
|
|
4768
|
+
# through the memory MCP tools, which query it — not by grepping raw checkpoint markdown.
|
|
4769
|
+
".gu-memory",
|
|
4767
4770
|
}
|
|
4768
4771
|
# Machine-generated files that are TEXT but never worth reading. A lockfile is the whole
|
|
4769
4772
|
# problem in miniature: in the reported Next.js project package-lock.json is 239KB while every
|
|
@@ -6695,6 +6698,9 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
6695
6698
|
_emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
|
|
6696
6699
|
elif command.startswith("__S3SETUP__::"):
|
|
6697
6700
|
_emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
|
|
6701
|
+
elif command.startswith("__MCPTOOL__::"):
|
|
6702
|
+
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
6703
|
+
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
6698
6704
|
else:
|
|
6699
6705
|
_emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
|
|
6700
6706
|
deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
|
|
@@ -7073,6 +7079,12 @@ def run_agent_chat(cfg):
|
|
|
7073
7079
|
# risky command and asks the user via the API instead of hard-blocking it.
|
|
7074
7080
|
_job_id = (cfg.get("jobId") or "").strip()
|
|
7075
7081
|
_interactive_approval = bool(cfg.get("interactiveApproval"))
|
|
7082
|
+
# Skills and MCP servers (task #269). The worker scans ~/.gonext/skills, ~/.gonext/mcp.json and
|
|
7083
|
+
# this folder's .gonext/skills + .mcp.json for every turn; none present = neither appears
|
|
7084
|
+
# anywhere in the prompt. Names only here — a skill's body is read when it is used.
|
|
7085
|
+
import gu_skills as _skills_mod
|
|
7086
|
+
_skills = _skills_mod.clean_list(cfg.get("skills"))
|
|
7087
|
+
_mcp_cfg = cfg.get("mcp") if isinstance(cfg.get("mcp"), dict) else {}
|
|
7076
7088
|
|
|
7077
7089
|
def _ask_s3_setup() -> bool:
|
|
7078
7090
|
"""Private mode, a file to hand over, no S3 of the user's own (task #252): ask in the
|
|
@@ -7230,6 +7242,40 @@ def run_agent_chat(cfg):
|
|
|
7230
7242
|
# below. Otherwise the keyword router matches URLs/"api"/"GET" from prior turns
|
|
7231
7243
|
# and fires the agent on trivial replies like "thanks" or "good".
|
|
7232
7244
|
latest_user_text = task_text
|
|
7245
|
+
# `/<skill> request` typed in the terminal (task #269): the skill's instructions go in with
|
|
7246
|
+
# the request, for this turn only — the history keeps the short line the user typed.
|
|
7247
|
+
_skill_call = _skills_mod.invocation(task_text, _skills)
|
|
7248
|
+
if _skill_call:
|
|
7249
|
+
_sk, _sk_body, _sk_req = _skill_call
|
|
7250
|
+
task_text = (_skills_mod.render(_sk, _sk_body) + "\n\nThe user's request: "
|
|
7251
|
+
+ (_sk_req or "(nothing more — do what the skill describes)"))
|
|
7252
|
+
_log(f"skill invoked: {_sk['name']}")
|
|
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}")
|
|
7233
7279
|
|
|
7234
7280
|
# Walk prior turns newest-first, keeping condensed lines until the budget is
|
|
7235
7281
|
# spent, then restore chronological (oldest→newest) order.
|
|
@@ -7862,6 +7908,11 @@ def run_agent_chat(cfg):
|
|
|
7862
7908
|
needs_agent = True
|
|
7863
7909
|
else:
|
|
7864
7910
|
needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
|
|
7911
|
+
# A skill the user typed is work to DO with tools — plain chat cannot follow one.
|
|
7912
|
+
if not needs_agent and _skill_call:
|
|
7913
|
+
_log("router → YES (skill invoked)")
|
|
7914
|
+
_emit({"type": "step", "text": "→ Agent mode (skill)"})
|
|
7915
|
+
needs_agent = True
|
|
7865
7916
|
# A pending email awaiting 'confirm' must reach the agent even though a bare
|
|
7866
7917
|
# 'confirm' / 'send it' is not a network keyword — otherwise the send never fires.
|
|
7867
7918
|
if not needs_agent and _email_confirm_pending():
|
|
@@ -8128,7 +8179,10 @@ def run_agent_chat(cfg):
|
|
|
8128
8179
|
# told to manage; a model that believes otherwise turns "own the environment" into
|
|
8129
8180
|
# "write the user instructions for owning it" — see _machine_context.
|
|
8130
8181
|
+ _machine_context(_sys_platform(), _sys_arch(), _WS_ACTIVE or "")
|
|
8131
|
-
|
|
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"
|
|
8132
8186
|
" * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
|
|
8133
8187
|
"state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
|
|
8134
8188
|
"(server responds, artifact exists, test green, file contains the change) before "
|
|
@@ -8270,6 +8324,7 @@ def run_agent_chat(cfg):
|
|
|
8270
8324
|
"When you have enough to answer, call final_answer(<your answer>) in a code block. "
|
|
8271
8325
|
"Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
|
|
8272
8326
|
"{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
|
|
8327
|
+
+ _skills_mod.prompt_block(_skills)
|
|
8273
8328
|
+ _rag_tool_block + _ws_tool_block + _auto_test_block +
|
|
8274
8329
|
"\n"
|
|
8275
8330
|
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
@@ -8383,8 +8438,9 @@ def run_agent_chat(cfg):
|
|
|
8383
8438
|
# Task #90: ~25-token echo of the autonomy contract at the highest-attention spot
|
|
8384
8439
|
# (recency — right next to the task), reinforcing the full directive that lives in
|
|
8385
8440
|
# the cached reference above. Workspace/coding mode only.
|
|
8386
|
-
+ ("Remember: FINISH
|
|
8387
|
-
"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"
|
|
8388
8444
|
if _WS_AVAILABLE else "")
|
|
8389
8445
|
+ "\nTASK (answer THIS, choose the tool that fits it):\n"
|
|
8390
8446
|
f"{task_text}\n"
|
|
@@ -12405,6 +12461,67 @@ def run_agent_chat(cfg):
|
|
|
12405
12461
|
# (S3 indexing) isn't configured.
|
|
12406
12462
|
if not _RAG_AVAILABLE:
|
|
12407
12463
|
agent_tools += [list_dir, read_text_file]
|
|
12464
|
+
# SKILLS (task #269): only offered when at least one exists, like every tool above.
|
|
12465
|
+
if _skills:
|
|
12466
|
+
@tool
|
|
12467
|
+
def use_skill(name: str) -> str:
|
|
12468
|
+
"""Load a skill's full instructions by its name from the SKILLS list. Call it FIRST when the task matches a skill, then follow what it returns.
|
|
12469
|
+
|
|
12470
|
+
Args:
|
|
12471
|
+
name: the skill's name, exactly as listed under SKILLS
|
|
12472
|
+
"""
|
|
12473
|
+
return _skills_mod.use_skill_text(_skills, name)
|
|
12474
|
+
|
|
12475
|
+
agent_tools.append(use_skill)
|
|
12476
|
+
# MCP SERVERS (task #269): every tool of every server that starts, named
|
|
12477
|
+
# mcp__<server>__<tool>. A server that does not start is a note for this turn, not a
|
|
12478
|
+
# failure of it. Each call passes _mcp_approve first. Not for the between-turns index job,
|
|
12479
|
+
# which calls no model and would start the servers for nothing.
|
|
12480
|
+
_mcp_servers = [s for s in (_mcp_cfg.get("servers") or [])
|
|
12481
|
+
if isinstance(s, dict) and s.get("name") and s.get("command")]
|
|
12482
|
+
if _mcp_servers and not cfg.get("ragIndexOnly"):
|
|
12483
|
+
import atexit as _atexit
|
|
12484
|
+
import gu_mcp as _mcp_mod
|
|
12485
|
+
from smolagents import Tool as _SmolTool
|
|
12486
|
+
_mcp_turn_allowed: set = set()
|
|
12487
|
+
|
|
12488
|
+
def _mcp_approve(server: str, tool_name: str, args: dict):
|
|
12489
|
+
"""deny → allow → ask (Claude Code's order). The rules are re-read from the file on
|
|
12490
|
+
every call: an "Always allow" answered a moment ago was written there."""
|
|
12491
|
+
allow, deny = _mcp_mod.read_rules(_mcp_cfg.get("configPath"),
|
|
12492
|
+
_mcp_cfg.get("allow") or [], _mcp_cfg.get("deny") or [])
|
|
12493
|
+
verdict = _mcp_mod.decide(allow, deny, server, tool_name)
|
|
12494
|
+
if verdict == "deny":
|
|
12495
|
+
return False, (f"MCP tool {server}/{tool_name} is blocked by a deny rule in "
|
|
12496
|
+
"~/.gonext/mcp.json. Do not call it again; carry on without it.")
|
|
12497
|
+
if verdict == "allow" or (server, tool_name) in _mcp_turn_allowed:
|
|
12498
|
+
return True, ""
|
|
12499
|
+
if not (_interactive_approval and _job_id and worker_api_base and worker_api_key):
|
|
12500
|
+
return False, (f"MCP tool {server}/{tool_name} needs the user's approval and this "
|
|
12501
|
+
"session cannot ask. Tell the user to allow it in the gu terminal "
|
|
12502
|
+
f"(/mcp allow {server}), then carry on without it.")
|
|
12503
|
+
preview = json.dumps(args, ensure_ascii=False, default=str)
|
|
12504
|
+
if len(preview) > 240:
|
|
12505
|
+
preview = preview[:240] + "…"
|
|
12506
|
+
ok = _ws_request_approval(worker_api_base, worker_api_key, _job_id,
|
|
12507
|
+
f"__MCPTOOL__::{server}\t{tool_name}\t{preview}", "")
|
|
12508
|
+
if ok:
|
|
12509
|
+
_mcp_turn_allowed.add((server, tool_name))
|
|
12510
|
+
return True, ""
|
|
12511
|
+
return False, (f"The user declined MCP tool {server}/{tool_name}. Do not call it again "
|
|
12512
|
+
"this turn; carry on without it or ask the user.")
|
|
12513
|
+
|
|
12514
|
+
_emit({"type": "step", "text": f"Starting {len(_mcp_servers)} MCP server(s)…"})
|
|
12515
|
+
_mcp_conn, _mcp_notes = _mcp_mod.connect_all(_mcp_servers, _WS_ACTIVE or None, log=_log)
|
|
12516
|
+
_atexit.register(lambda: [c.close() for c, _t in _mcp_conn])
|
|
12517
|
+
for _note in _mcp_notes:
|
|
12518
|
+
_emit({"type": "step", "text": _note})
|
|
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)")
|
|
12408
12525
|
# Fill in the numbered tool list now that the REAL tool objects exist — see
|
|
12409
12526
|
# _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
|
|
12410
12527
|
task_with_hint = task_with_hint.replace(
|
|
@@ -12579,6 +12696,12 @@ def run_agent_chat(cfg):
|
|
|
12579
12696
|
final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
|
|
12580
12697
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
12581
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}")
|
|
12582
12705
|
# Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
|
|
12583
12706
|
# final_answer, or a deterministic PDF delivery) — that content is now in the
|
|
12584
12707
|
# answer pushed to conversation history, so the raw trace is no longer needed.
|
package/gu-cli.mjs
CHANGED
|
@@ -33,6 +33,40 @@ import { createRequire } from "node:module";
|
|
|
33
33
|
import dotenv from "dotenv";
|
|
34
34
|
import OpenAI from "openai";
|
|
35
35
|
import { resolvePython } from "./platform-tools.mjs";
|
|
36
|
+
import { skillsForTurn } from "./skills.mjs";
|
|
37
|
+
import { mcpForTurn } from "./mcp-config.mjs";
|
|
38
|
+
import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
|
|
39
|
+
|
|
40
|
+
/** The `skills` / `mcp` fields of one job's agent config (task #269). A broken config is a log
|
|
41
|
+
* line, never a failed turn — the turn runs without them. */
|
|
42
|
+
function skillsAndMcpForJob(cwd) {
|
|
43
|
+
const out = {};
|
|
44
|
+
try {
|
|
45
|
+
const skills = skillsForTurn({ cwd });
|
|
46
|
+
if (skills.length) out.skills = skills;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.log(`[gu-worker] skills skipped: ${e?.message || e}`);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const mcp = mcpForTurn({ cwd });
|
|
52
|
+
if (mcp.servers.length) out.mcp = mcp;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.log(`[gu-worker] MCP servers skipped: ${e?.message || e}`);
|
|
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
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
36
70
|
|
|
37
71
|
const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
|
|
38
72
|
|
|
@@ -223,6 +257,26 @@ if (args[0] === "token") {
|
|
|
223
257
|
process.exit(0);
|
|
224
258
|
}
|
|
225
259
|
|
|
260
|
+
if (args[0] === "mcp") {
|
|
261
|
+
// #269. MCP servers from a shell — `claude mcp add`'s shape. Same code as /mcp in the terminal UI.
|
|
262
|
+
const { runMcpCommand } = await import("./mcp-command.mjs");
|
|
263
|
+
const code = await runMcpCommand(args.slice(1), {
|
|
264
|
+
cwd: process.cwd(),
|
|
265
|
+
print: (line, kind) => (kind === "warn" ? console.error(line) : console.log(line)),
|
|
266
|
+
});
|
|
267
|
+
process.exit(code);
|
|
268
|
+
}
|
|
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
|
+
|
|
226
280
|
if (args[0] === "full-log") {
|
|
227
281
|
// #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
|
|
228
282
|
// token minted on the web admin page. Same code as /full-log in the terminal UI.
|
|
@@ -3836,6 +3890,10 @@ async function runAgentChatJob(job) {
|
|
|
3836
3890
|
// Task #134: set when the terminal's folder is on ANOTHER machine. The agent surfaces
|
|
3837
3891
|
// it to the user — a silent downgrade to "no file tools" would just look broken.
|
|
3838
3892
|
...(remoteWorkspace ? { remoteWorkspace } : {}),
|
|
3893
|
+
// Skills and MCP servers (task #269), read from disk for EVERY job so an edited, added or
|
|
3894
|
+
// deleted SKILL.md / server takes effect on the next turn with no restart. The project's
|
|
3895
|
+
// own ones only when its folder is on this machine. None = the keys are absent.
|
|
3896
|
+
...skillsAndMcpForJob(terminalActiveWs && wsExistsHere ? terminalActiveWs : null),
|
|
3839
3897
|
});
|
|
3840
3898
|
// Agent run time budget (task #81). Default 60 min; user-configurable in web Settings
|
|
3841
3899
|
// (agentTimeoutMinutes → payload.agentTimeoutMs). Multi-step ReAct on a 14B/31B with a
|
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,8 +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";
|
|
124
|
+
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
125
|
+
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
126
|
+
import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
127
|
+
import { readHooks } from "./hooks-config.mjs";
|
|
122
128
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
123
129
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
124
130
|
import {
|
|
@@ -532,7 +538,7 @@ const argv = process.argv.slice(2);
|
|
|
532
538
|
// process.argv and exits inside its own subcommand branch, before it ever starts polling.
|
|
533
539
|
const CLI_SUBCOMMANDS = [
|
|
534
540
|
"status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
|
|
535
|
-
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log",
|
|
541
|
+
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks",
|
|
536
542
|
];
|
|
537
543
|
// `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
|
|
538
544
|
// the list, so it fell through to "no subcommand given" and a full agent session opened in that
|
|
@@ -807,6 +813,8 @@ const COMMANDS = [
|
|
|
807
813
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
808
814
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
809
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" },
|
|
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" },
|
|
810
818
|
{ name: "/help", desc: "list these commands" },
|
|
811
819
|
{ name: "/exit", aliases: ["/quit"], desc: "quit" },
|
|
812
820
|
];
|
|
@@ -814,22 +822,50 @@ const COMMANDS = [
|
|
|
814
822
|
// terminal-layout.mjs — their handlers are below, above the unknown-command guard. Not
|
|
815
823
|
// imported here: nothing in the REPL reads the list, the tests do (task #153).
|
|
816
824
|
const COMMAND_NAMES = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]);
|
|
825
|
+
// SKILLS ARE COMMANDS TOO (task #269): each ~/.gonext/skills/<name>/SKILL.md (and this folder's
|
|
826
|
+
// .gonext/skills) is /<name>, in Tab-completion and /help like any other. Scanned from disk — kept
|
|
827
|
+
// for 2s only, because the overlay asks on every keystroke — so a skill added, edited or DELETED
|
|
828
|
+
// while gu runs shows up (or quietly disappears) without a restart. A built-in name always wins.
|
|
829
|
+
let _skillScan = { at: 0, skills: [] };
|
|
830
|
+
function currentSkills() {
|
|
831
|
+
if (Date.now() - _skillScan.at > 2000) {
|
|
832
|
+
try {
|
|
833
|
+
_skillScan = { at: Date.now(), skills: discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).skills };
|
|
834
|
+
} catch {
|
|
835
|
+
_skillScan = { at: Date.now(), skills: [] };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return _skillScan.skills;
|
|
839
|
+
}
|
|
840
|
+
const skillCommands = () =>
|
|
841
|
+
currentSkills()
|
|
842
|
+
.filter((s) => s.slash)
|
|
843
|
+
.map((s) => ({ name: `/${s.name}`, usage: `/${s.name} [request]`, desc: `skill — ${firstSentence(s.description)}`, skill: true }));
|
|
844
|
+
/** A skill's description is written for the MODEL and often runs a paragraph; /help shows one line. */
|
|
845
|
+
function firstSentence(text, max = 100) {
|
|
846
|
+
const one = String(text ?? "").split(/(?<=[.!?])\s/)[0].trim();
|
|
847
|
+
return one.length > max ? one.slice(0, max - 1).trimEnd() + "…" : one;
|
|
848
|
+
}
|
|
849
|
+
const allCommands = () => [...COMMANDS, ...skillCommands()];
|
|
850
|
+
const allCommandNames = () => [...COMMAND_NAMES, ...skillCommands().map((c) => c.name)];
|
|
817
851
|
// Tab-completion: when the line starts with "/", complete against the command names.
|
|
818
852
|
function slashCompleter(line) {
|
|
819
853
|
if (!line.startsWith("/")) return [[], line];
|
|
820
|
-
const
|
|
821
|
-
|
|
854
|
+
const names = allCommandNames();
|
|
855
|
+
const hits = names.filter((n) => n.startsWith(line));
|
|
856
|
+
return [hits.length ? hits : names, line];
|
|
822
857
|
}
|
|
823
858
|
// Commands whose name/alias matches the typed prefix (all when prefix is "" or "/").
|
|
824
859
|
function filteredCommands(prefix = "") {
|
|
825
860
|
const p = (prefix ?? "").trim();
|
|
826
|
-
const
|
|
861
|
+
const cmds = allCommands();
|
|
862
|
+
const m = cmds.filter(
|
|
827
863
|
(c) =>
|
|
828
864
|
!p ||
|
|
829
865
|
p === "/" ||
|
|
830
866
|
[c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(p))
|
|
831
867
|
);
|
|
832
|
-
return m.length ? m :
|
|
868
|
+
return m.length ? m : cmds;
|
|
833
869
|
}
|
|
834
870
|
// Display rows for a filtered command set; row `sel` (if >=0) is highlighted (❯ + bold).
|
|
835
871
|
function hintRows(cmds, sel) {
|
|
@@ -1648,14 +1684,28 @@ function slashOverlaySeq(line, cursor, menu, cols) {
|
|
|
1648
1684
|
}
|
|
1649
1685
|
|
|
1650
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
|
+
}
|
|
1651
1699
|
function renderSlashOverlay(sel) {
|
|
1652
1700
|
const line = rl.line;
|
|
1653
|
-
const cmds =
|
|
1701
|
+
const cmds = rankSlashCommands(allCommands(), line);
|
|
1654
1702
|
slashNames = cmds.map((c) => c.name);
|
|
1655
1703
|
if (sel >= cmds.length) sel = cmds.length - 1;
|
|
1656
1704
|
slashSel = sel;
|
|
1657
1705
|
slashPrefix = line;
|
|
1658
|
-
const
|
|
1706
|
+
const win = slashMenuWindow(cmds.length, sel, slashTop);
|
|
1707
|
+
slashTop = win.top;
|
|
1708
|
+
const menu = slashMenuRows(cmds, sel, win, line);
|
|
1659
1709
|
if (!slashOverlayActive) {
|
|
1660
1710
|
slashOverlayActive = true;
|
|
1661
1711
|
slashNeutralizeHistory();
|
|
@@ -1680,6 +1730,7 @@ function closeSlashOverlay() {
|
|
|
1680
1730
|
slashOverlayActive = false;
|
|
1681
1731
|
slashHintRows = 0;
|
|
1682
1732
|
slashSel = -1;
|
|
1733
|
+
slashTop = 0;
|
|
1683
1734
|
slashNames = [];
|
|
1684
1735
|
slashRestoreHistory();
|
|
1685
1736
|
}
|
|
@@ -4404,6 +4455,23 @@ async function loadSessionUnfinishedTask(cwd) {
|
|
|
4404
4455
|
* `mutate` receives the parsed object and may return a replacement or mutate in place.
|
|
4405
4456
|
*/
|
|
4406
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
|
+
|
|
4407
4475
|
async function updateSessionFile(cwd, mutate) {
|
|
4408
4476
|
const run = async () => {
|
|
4409
4477
|
let raw = {};
|
|
@@ -7185,6 +7253,21 @@ async function runFullLogCommand(line) {
|
|
|
7185
7253
|
console.log("");
|
|
7186
7254
|
}
|
|
7187
7255
|
|
|
7256
|
+
/**
|
|
7257
|
+
* /mcp (#269) — the same code as `gu mcp`. `/mcp add` alone asks step by step; its questions go
|
|
7258
|
+
* through ask(), whose label is drawn in the frozen row (a readline prompt alone is invisible there).
|
|
7259
|
+
*/
|
|
7260
|
+
async function runMcpSlashCommand(line) {
|
|
7261
|
+
const { runMcpCommand, splitWords } = await import("./mcp-command.mjs");
|
|
7262
|
+
const style = { ok: accent, warn: yellow, dim };
|
|
7263
|
+
await runMcpCommand(splitWords(line).slice(1), {
|
|
7264
|
+
cwd: process.cwd(),
|
|
7265
|
+
ask: (label) => ask(label + " "),
|
|
7266
|
+
print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
|
|
7267
|
+
});
|
|
7268
|
+
console.log("");
|
|
7269
|
+
}
|
|
7270
|
+
|
|
7188
7271
|
async function runProxyCommand(line) {
|
|
7189
7272
|
const cmd = await import("./proxy-command.mjs");
|
|
7190
7273
|
const store = await import("./proxy-store.mjs");
|
|
@@ -8250,6 +8333,10 @@ const COMPACT_PREFIX = "__COMPACT__::";
|
|
|
8250
8333
|
// Private mode, a file to hand over, and no S3 of the user's own (task #252): set it up now, or
|
|
8251
8334
|
// save the file on this computer. Allow = S3 is now set up AND passed its test.
|
|
8252
8335
|
const S3SETUP_PREFIX = "__S3SETUP__::";
|
|
8336
|
+
// An MCP tool about to run for the first time (task #269): "<server>\t<tool>\t<args preview>".
|
|
8337
|
+
// "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
|
|
8338
|
+
// call, so it counts from the next call of this same turn, and for every turn after.
|
|
8339
|
+
const MCPTOOL_PREFIX = "__MCPTOOL__::";
|
|
8253
8340
|
async function approvalPrompt(command) {
|
|
8254
8341
|
// Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
|
|
8255
8342
|
// redrawing it (the tick early-returns while a picker is active) — otherwise the block
|
|
@@ -8307,6 +8394,29 @@ async function approvalPrompt(command) {
|
|
|
8307
8394
|
);
|
|
8308
8395
|
return { allow: idx === 0, dontAsk: false };
|
|
8309
8396
|
}
|
|
8397
|
+
if (command.startsWith(MCPTOOL_PREFIX)) {
|
|
8398
|
+
const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
|
|
8399
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
8400
|
+
process.stdout.write(
|
|
8401
|
+
"\n" + yellow(`⚙ Allow the MCP tool ${server}/${tool}?`) + dim(" (no answer in ~3 min = No)") + "\n" +
|
|
8402
|
+
(preview ? dim(" " + preview) + "\n" : "")
|
|
8403
|
+
);
|
|
8404
|
+
const idx = await pickFromList(
|
|
8405
|
+
["Yes", `Yes, and always allow ${server}/${tool}`, `Yes, and always allow every ${server} tool`, "No"],
|
|
8406
|
+
0,
|
|
8407
|
+
{ timeoutMs: 170000, timeoutIndex: 3 }
|
|
8408
|
+
);
|
|
8409
|
+
if (idx === 1 || idx === 2) {
|
|
8410
|
+
const rule = idx === 1 ? `mcp__${server}__${tool}` : `mcp__${server}__*`;
|
|
8411
|
+
try {
|
|
8412
|
+
addAllowRule({ rule });
|
|
8413
|
+
process.stdout.write(dim(` (won't ask again: ${rule} — undo with /mcp disallow ${server})\n`));
|
|
8414
|
+
} catch (e) {
|
|
8415
|
+
process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
|
|
8416
|
+
}
|
|
8417
|
+
}
|
|
8418
|
+
return { allow: idx >= 0 && idx <= 2, dontAsk: false };
|
|
8419
|
+
}
|
|
8310
8420
|
const isMax = command.startsWith(MAXSTEP_PREFIX);
|
|
8311
8421
|
if (isMax) {
|
|
8312
8422
|
const label = command.slice(MAXSTEP_PREFIX.length);
|
|
@@ -10665,8 +10775,42 @@ async function main() {
|
|
|
10665
10775
|
const plan = acct.plan ? (acct.pro ? accent(acct.plan) : dim(acct.plan)) : "";
|
|
10666
10776
|
statusRows.push(acct.email ? dim(`account ${acct.email}`) + (plan ? dim(" · ") + plan : "") : dim("plan ") + plan);
|
|
10667
10777
|
}
|
|
10778
|
+
// Skills and MCP servers (task #269) — one row, only when there are any.
|
|
10779
|
+
try {
|
|
10780
|
+
const nSkills = currentSkills().length;
|
|
10781
|
+
const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
|
|
10782
|
+
const bits = [nSkills ? `${nSkills} skill${nSkills === 1 ? "" : "s"}` : "", nMcp ? `${nMcp} MCP server${nMcp === 1 ? "" : "s"}` : ""].filter(Boolean);
|
|
10783
|
+
if (bits.length) statusRows.push(dim(bits.join(" · ") + " (/help · /mcp)"));
|
|
10784
|
+
} catch {
|
|
10785
|
+
/* a broken file is reported below; the banner is not the place to fail */
|
|
10786
|
+
}
|
|
10668
10787
|
console.log(renderBanner(statusRows));
|
|
10669
10788
|
}
|
|
10789
|
+
// Skills and MCP (task #269): say what is BROKEN or WAITING, and nothing when all is well. A
|
|
10790
|
+
// deleted skill is not broken — it is simply gone, and says nothing.
|
|
10791
|
+
try {
|
|
10792
|
+
for (const w of discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).warnings) {
|
|
10793
|
+
console.log(GUTTER + yellow(w));
|
|
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
|
+
}
|
|
10804
|
+
const waiting = unapprovedProjectServers(process.cwd());
|
|
10805
|
+
if (waiting.length) {
|
|
10806
|
+
console.log(
|
|
10807
|
+
GUTTER + yellow(`This folder's .mcp.json names ${waiting.length} MCP server(s) that will not start until you approve them: ${waiting.map((x) => x.name).join(", ")}`) +
|
|
10808
|
+
"\n" + GUTTER + dim(" /mcp shows the command each one runs · /mcp approve <name>")
|
|
10809
|
+
);
|
|
10810
|
+
}
|
|
10811
|
+
} catch {
|
|
10812
|
+
/* never block startup on this */
|
|
10813
|
+
}
|
|
10670
10814
|
// Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
|
|
10671
10815
|
// (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
|
|
10672
10816
|
// model is left untouched, and this is skipped entirely. Interactive TTYs only.
|
|
@@ -10966,6 +11110,8 @@ async function main() {
|
|
|
10966
11110
|
|
|
10967
11111
|
const cwd = resolve(process.cwd());
|
|
10968
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);
|
|
10969
11115
|
if (history.length > 0) {
|
|
10970
11116
|
const turns = history.filter((m) => m.role === "user").length;
|
|
10971
11117
|
console.log(
|
|
@@ -11220,6 +11366,20 @@ async function main() {
|
|
|
11220
11366
|
await runFullLogCommand(line);
|
|
11221
11367
|
continue;
|
|
11222
11368
|
}
|
|
11369
|
+
if (line === "/mcp" || line.startsWith("/mcp ")) {
|
|
11370
|
+
await runMcpSlashCommand(line);
|
|
11371
|
+
continue;
|
|
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
|
+
}
|
|
11223
11383
|
if (line === "/proxy" || line.startsWith("/proxy ")) {
|
|
11224
11384
|
await runProxyCommand(line);
|
|
11225
11385
|
continue;
|
|
@@ -11312,6 +11472,7 @@ async function main() {
|
|
|
11312
11472
|
if (line === "/reset" || line === "/new") {
|
|
11313
11473
|
history.length = 0;
|
|
11314
11474
|
await clearSession(cwd);
|
|
11475
|
+
await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
|
|
11315
11476
|
console.log(GUTTER + dim("Session cleared — starting fresh.\n"));
|
|
11316
11477
|
continue;
|
|
11317
11478
|
}
|
|
@@ -11381,7 +11542,10 @@ async function main() {
|
|
|
11381
11542
|
}
|
|
11382
11543
|
// Any OTHER "/…" is a typo/unknown command — don't send it to the agent as a
|
|
11383
11544
|
// question; point the user at /help (all real commands were handled above).
|
|
11384
|
-
|
|
11545
|
+
// …EXCEPT a skill (task #269): "/<skill> request" goes to the agent as typed, and the agent
|
|
11546
|
+
// loads that skill's SKILL.md for the turn. Checked against the disk NOW, so a skill deleted a
|
|
11547
|
+
// moment ago is an unknown command like any other, not an error.
|
|
11548
|
+
if (line.startsWith("/") && !matchSkillCommand(line, currentSkills())) {
|
|
11385
11549
|
const cmd = line.split(/\s+/)[0];
|
|
11386
11550
|
console.log(GUTTER + dim(`unknown command: ${cmd} — type /help (or / then Tab) to list commands\n`));
|
|
11387
11551
|
continue;
|