@tiens.nguyen/gu-cli 1.0.883 → 1.0.885
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/README.md +34 -18
- package/gu-repl.mjs +156 -1
- package/hooks-config.mjs +3 -1
- package/package.json +4 -1
- package/resume-memory.mjs +85 -0
- package/skill-install.mjs +563 -0
- package/skill-package.mjs +455 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
<h1 align="center">gu — the coding agent
|
|
1
|
+
<h1 align="center">gu — the coding agent for your own models</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>
|
|
5
|
-
|
|
4
|
+
<strong>A coding agent in your terminal that runs on Ollama, MLX or any OpenAI-compatible API.</strong><br>
|
|
5
|
+
It reads your repo, makes the change, runs the tests and shows you what it did. Your code stays in your folder.<br>
|
|
6
6
|
<strong>Free for 30 days. No account, no sign-in, no card.</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<a href="https://gucli.com">Website</a> ·
|
|
19
|
-
<a href="https://gucli.com/
|
|
19
|
+
<a href="https://gucli.com/start">Docs</a> ·
|
|
20
20
|
<a href="https://gucli.com/pricing">Pricing</a> ·
|
|
21
21
|
<a href="https://gucli.com/support">Support</a>
|
|
22
22
|
</p>
|
|
@@ -42,19 +42,32 @@ provider to use. Then type what you want done.
|
|
|
42
42
|
|
|
43
43
|
> **`gu: command not found`?** Run `npx @tiens.nguyen/gu-cli fix-path`. See [Troubleshooting](#troubleshooting).
|
|
44
44
|
|
|
45
|
-
##
|
|
46
|
-
|
|
47
|
-
- **
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
- **
|
|
55
|
-
|
|
56
|
-
- **
|
|
57
|
-
|
|
45
|
+
## Why gu
|
|
46
|
+
|
|
47
|
+
- **Your models, your choice.** Run [Ollama](https://ollama.com) or MLX on your own machine or a GPU
|
|
48
|
+
box on your network, or bring any OpenAI-compatible provider (OpenAI, DeepSeek, OpenRouter…). Use
|
|
49
|
+
one model to plan and another to code; switch any time with `/model`.
|
|
50
|
+
- **Private by default.** Logs hold no prompts, code or keys unless you run `gu --log`. Need
|
|
51
|
+
everything in-house? Private mode runs the API and database on your own servers.
|
|
52
|
+
- **It proves it works.** gu runs the build and the tests and fixes what fails. For web pages, the
|
|
53
|
+
`browser` tool drives a real Chrome and checks the result before gu says it is done.
|
|
54
|
+
- **It stays in the repo.** Every edit is a file you can read, diff and revert; `/undo` puts back
|
|
55
|
+
what a stopped turn changed. No hidden workspace.
|
|
56
|
+
- **Made for servers and SSH.** macOS, Linux and Windows, and it works over SSH in an 80-column
|
|
57
|
+
terminal, so you can run it where the code actually lives.
|
|
58
|
+
- **Fits the setup you have.** [MCP](https://modelcontextprotocol.io) servers, skills (`SKILL.md`)
|
|
59
|
+
and hooks. Already use Claude Code? `/switch-skill claude` reuses its `.claude` folder. Local RAG
|
|
60
|
+
indexes the folder so the model finds the right files.
|
|
61
|
+
|
|
62
|
+
## Is gu for you?
|
|
63
|
+
|
|
64
|
+
**A good fit if** you run (or want to run) your own models, can't send code to someone else's
|
|
65
|
+
cloud, work on remote Linux boxes over SSH, or want a coding agent that costs $4 a month instead
|
|
66
|
+
of a model subscription.
|
|
67
|
+
|
|
68
|
+
**Probably not if** you want the strongest frontier model with zero setup and don't mind where your
|
|
69
|
+
code goes: the model vendors' own agents are simpler for that. gu can still use those models
|
|
70
|
+
through an OpenAI-compatible API, but its strength is everything else.
|
|
58
71
|
|
|
59
72
|
## Quick start
|
|
60
73
|
|
|
@@ -113,6 +126,9 @@ Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab
|
|
|
113
126
|
| `/mcp [list\|tools\|add\|remove\|enable\|disable\|approve\|allow\|disallow\|reconnect]` | MCP servers, and which of their tools run without asking |
|
|
114
127
|
| `/hooks [list\|approve\|unapprove]` | commands gu runs on `onStart`, `onAsk`, `onToolDone`, `onTurnEnd` |
|
|
115
128
|
| `/switch-skill [claude\|gu]` | use Claude Code's (`.claude`) or gu's (`.gonext`) skills, MCP servers and hooks in this folder |
|
|
129
|
+
| `/skill-pack <name> [--out <file>] [--runtime <dir>]` | pack a skill with its MCP servers, hooks and code into one zip for another machine (no venvs, databases or secrets) |
|
|
130
|
+
| `/skill-install <zip\|folder> [--gu] [--claude] [--dry-run]` | install a packed skill for gu and Claude Code; without gu: `node <name>/install.mjs` |
|
|
131
|
+
| `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's: shows its plan, last command and next step, then asks (needs the memory skill) |
|
|
116
132
|
| `/skills-copy-to-claude [--dry-run] [name…]` | copy gu's skills into Claude Code's folders |
|
|
117
133
|
| `/proxy [show\|set\|on\|off\|clear\|test]` | route model calls through an HTTP proxy (local calls go direct) |
|
|
118
134
|
| `/s3 [test]` | use your own S3 bucket for the RAG index and generated files |
|
|
@@ -168,7 +184,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
|
168
184
|
| --- | --- | --- |
|
|
169
185
|
| **Free trial** | 30 days, starts on the first run | nothing: the token is created for you |
|
|
170
186
|
| **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
|
|
171
|
-
| **Pro** | while subscribed |
|
|
187
|
+
| **Pro** | while subscribed: **$4 a month** (110.000 ₫) + VAT, or $40 a year | the hosted agent and coder (no model server to run), up to 5 computers, provider budgets |
|
|
172
188
|
|
|
173
189
|
Got a **voucher code**? Type `/voucher CODE` in gu (or enter it on your
|
|
174
190
|
[account page](https://gucli.com/account)) for extra free days on top.
|
package/gu-repl.mjs
CHANGED
|
@@ -1051,6 +1051,9 @@ const COMMANDS = [
|
|
|
1051
1051
|
{ name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
|
|
1052
1052
|
{ 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" },
|
|
1053
1053
|
{ name: "/switch-skill", usage: "/switch-skill [claude|gu]", desc: "whose skills, MCP servers and hooks this folder uses — Claude Code's (.claude) or gu's own (.gonext)" },
|
|
1054
|
+
{ name: "/resume", desc: "carry on the last unfinished task in this folder — Claude Code's or gu's — from where it stopped (memory skill)" },
|
|
1055
|
+
{ name: "/skill-pack", usage: "/skill-pack <name> [--out <file>] [--runtime <dir>]", desc: "pack a skill (gu's or Claude's) with its MCP servers, hooks and code into one zip for another machine" },
|
|
1056
|
+
{ name: "/skill-install", usage: "/skill-install <zip|folder> [--gu] [--claude] [--dry-run]", desc: "install a packed skill for gu and Claude Code (whichever is set up here)" },
|
|
1054
1057
|
{ name: "/skills-copy-to-claude", usage: "/skills-copy-to-claude [--dry-run] [name…]", desc: "copy gu's skills (.gonext) into Claude Code's folders (.claude), renamed to Claude's names, with their MCP servers" },
|
|
1055
1058
|
{ name: "/help", desc: "list these commands" },
|
|
1056
1059
|
{ name: "/exit", aliases: ["/quit"], desc: "quit" },
|
|
@@ -7787,6 +7790,140 @@ async function runSwitchSkillCommand(line) {
|
|
|
7787
7790
|
if (barPinned()) paintBar();
|
|
7788
7791
|
}
|
|
7789
7792
|
|
|
7793
|
+
/** "--out x" style option from a command line's words → value, or undefined. */
|
|
7794
|
+
function optionValue(words, flag) {
|
|
7795
|
+
const i = words.indexOf(flag);
|
|
7796
|
+
return i >= 0 ? words[i + 1] : undefined;
|
|
7797
|
+
}
|
|
7798
|
+
|
|
7799
|
+
/**
|
|
7800
|
+
* /skill-pack <name> [--out <file>] [--runtime <dir>] — one skill, with its MCP servers, hooks and
|
|
7801
|
+
* the code they run, into one zip another machine installs (#281, skill-package.mjs). Shows what
|
|
7802
|
+
* it found, then writes; a secret-looking line refuses the pack.
|
|
7803
|
+
*/
|
|
7804
|
+
async function runResumeCommand() {
|
|
7805
|
+
const rm = await import("./resume-memory.mjs");
|
|
7806
|
+
const { hooksForTurn } = await import("./hooks-config.mjs");
|
|
7807
|
+
const say = (l) => console.log(GUTTER + l);
|
|
7808
|
+
console.log("");
|
|
7809
|
+
const r = rm.readResume({ cwd: process.cwd(), found: rm.findMemoryCli({ cwd: process.cwd(), hooksFor: hooksForTurn }) });
|
|
7810
|
+
if (r.error) {
|
|
7811
|
+
say(yellow(r.error) + "\n");
|
|
7812
|
+
return null;
|
|
7813
|
+
}
|
|
7814
|
+
if (!r.resume) {
|
|
7815
|
+
say(dim("Nothing to resume here: every recent session finished its plan.\n"));
|
|
7816
|
+
return null;
|
|
7817
|
+
}
|
|
7818
|
+
for (const l of r.block.split("\n").filter((l) => !l.startsWith("If the user wants"))) say(l.startsWith("## ") ? bold(l.slice(3)) : dim(l));
|
|
7819
|
+
console.log("");
|
|
7820
|
+
if (!(await askYesNo(rm.resumeQuestion(r.resume), true))) {
|
|
7821
|
+
console.log("");
|
|
7822
|
+
return null;
|
|
7823
|
+
}
|
|
7824
|
+
return rm.resumePrompt(r.block, r.resume);
|
|
7825
|
+
}
|
|
7826
|
+
|
|
7827
|
+
async function runSkillPackCommand(line) {
|
|
7828
|
+
const sp = await import("./skill-package.mjs");
|
|
7829
|
+
const words = line.trim().split(/\s+/).slice(1);
|
|
7830
|
+
const out = optionValue(words, "--out");
|
|
7831
|
+
const runtime = optionValue(words, "--runtime");
|
|
7832
|
+
const name = words.find((w, i) => !w.startsWith("--") && !["--out", "--runtime"].includes(words[i - 1]));
|
|
7833
|
+
const home = homedir();
|
|
7834
|
+
const cwd = resolve(process.cwd());
|
|
7835
|
+
const tilde = (p) => (String(p).startsWith(home) ? "~" + String(p).slice(home.length) : p);
|
|
7836
|
+
const say = (l) => console.log(GUTTER + l);
|
|
7837
|
+
console.log("");
|
|
7838
|
+
if (!name) {
|
|
7839
|
+
say(dim("usage: /skill-pack <name> [--out <file>] [--runtime <dir>]\n"));
|
|
7840
|
+
return;
|
|
7841
|
+
}
|
|
7842
|
+
let plan;
|
|
7843
|
+
try {
|
|
7844
|
+
plan = sp.planSkillPack(name, { cwd, home, runtime });
|
|
7845
|
+
} catch (err) {
|
|
7846
|
+
say(red(`${err.message}\n`));
|
|
7847
|
+
return;
|
|
7848
|
+
}
|
|
7849
|
+
const f = plan.found;
|
|
7850
|
+
say(bold(`Pack ${plan.manifest.name}`) + dim(` (${f.source === "gu" ? "gu's" : "Claude Code's"} ${f.scope === "project" ? "project" : "user"} skill)`));
|
|
7851
|
+
say(dim(` skill ${tilde(f.skillDir)} ${plan.skill.size} file${plan.skill.size === 1 ? "" : "s"}`));
|
|
7852
|
+
if (f.runtimeDir) say(dim(` runtime ${tilde(f.runtimeDir)} ${plan.runtime.size} files${f.python ? " · venv rebuilt from requirements.txt" : ""}`));
|
|
7853
|
+
for (const s of f.servers) say(dim(` MCP ${s}`));
|
|
7854
|
+
if (f.hooks.length) say(dim(` hooks ${f.hooks.join(", ")}`));
|
|
7855
|
+
if (plan.manifest.env?.length) say(dim(` asked on install, not packed: ${plan.manifest.env.join(", ")}`));
|
|
7856
|
+
if (plan.skipped.length) say(dim(` left out ${plan.skipped.join(", ")}`));
|
|
7857
|
+
for (const w of plan.warnings) say(yellow(` ${w}`));
|
|
7858
|
+
if (plan.secrets.length) {
|
|
7859
|
+
for (const s of plan.secrets) say(red(` ✗ ${s.file}:${s.line} looks like ${s.kind}`));
|
|
7860
|
+
say(red(" Not packed — remove those and run /skill-pack again.\n"));
|
|
7861
|
+
return;
|
|
7862
|
+
}
|
|
7863
|
+
const file = resolve(out ?? `${plan.manifest.name}.zip`);
|
|
7864
|
+
try {
|
|
7865
|
+
const r = sp.writeSkillPack(plan, file);
|
|
7866
|
+
say(`${accent("✓")} ${tilde(r.file)} ${dim(`${Math.ceil(r.bytes / 1024)} KB, ${r.count} files`)}`);
|
|
7867
|
+
say(dim(` On the other machine: /skill-install ${file.split(/[\\/]/).pop()}`));
|
|
7868
|
+
// platform-ok: shown to the user — the folder inside the zip, which unzip makes with "/" names.
|
|
7869
|
+
say(dim(` Without gu: unzip it, then node ${plan.manifest.name}/install.mjs\n`));
|
|
7870
|
+
} catch (err) {
|
|
7871
|
+
say(red(`couldn't write the zip — ${err.message}\n`));
|
|
7872
|
+
}
|
|
7873
|
+
}
|
|
7874
|
+
|
|
7875
|
+
/**
|
|
7876
|
+
* /skill-install <zip|folder> [--gu] [--claude] [--dry-run] — a packed skill onto this machine
|
|
7877
|
+
* (skill-install.mjs, the same code as the package's own install.mjs). Shows the plan, then asks.
|
|
7878
|
+
*/
|
|
7879
|
+
async function runSkillInstallCommand(line) {
|
|
7880
|
+
const si = await import("./skill-install.mjs");
|
|
7881
|
+
const words = line.trim().split(/\s+/).slice(1);
|
|
7882
|
+
const src = words.find((w) => !w.startsWith("--"));
|
|
7883
|
+
const targets = si.TARGETS.filter((t) => words.includes(`--${t}`));
|
|
7884
|
+
const home = homedir();
|
|
7885
|
+
const tilde = (p) => String(p).split(home).join("~");
|
|
7886
|
+
const say = (l) => console.log(GUTTER + l);
|
|
7887
|
+
console.log("");
|
|
7888
|
+
if (!src) {
|
|
7889
|
+
say(dim("usage: /skill-install <zip|folder> [--gu] [--claude] [--dry-run]\n"));
|
|
7890
|
+
return;
|
|
7891
|
+
}
|
|
7892
|
+
let plan;
|
|
7893
|
+
try {
|
|
7894
|
+
plan = si.planInstall(si.loadPackage(resolve(src.replace(/^~(?=\/|$)/, home))), { home, targets });
|
|
7895
|
+
} catch (err) {
|
|
7896
|
+
say(red(`Not installed — ${err.message}\n`));
|
|
7897
|
+
return;
|
|
7898
|
+
}
|
|
7899
|
+
const lines = si.describePlan(plan, tilde);
|
|
7900
|
+
say(bold(lines[0]));
|
|
7901
|
+
for (const l of lines.slice(1)) say(l.trimStart().startsWith("!") ? yellow(l) : dim(l));
|
|
7902
|
+
if (!si.planHasWork(plan)) {
|
|
7903
|
+
say(dim("\n Nothing to do — it is already installed.\n"));
|
|
7904
|
+
return;
|
|
7905
|
+
}
|
|
7906
|
+
if (words.includes("--dry-run")) {
|
|
7907
|
+
say(dim("\n --dry-run: nothing was written.\n"));
|
|
7908
|
+
return;
|
|
7909
|
+
}
|
|
7910
|
+
console.log(dim(`\n ${PICK_HINT}:`));
|
|
7911
|
+
const options = ["Install", "Cancel"];
|
|
7912
|
+
const idx = await pickFromList(options, 0);
|
|
7913
|
+
if (idx !== 0) {
|
|
7914
|
+
console.log(dim(" (cancelled — nothing installed)\n"));
|
|
7915
|
+
paintBar();
|
|
7916
|
+
return;
|
|
7917
|
+
}
|
|
7918
|
+
const r = si.applyInstall(plan, { say: (l) => say(dim(l)) });
|
|
7919
|
+
for (const d of r.done) say(`${accent("✓")} ${dim(tilde(d))}`);
|
|
7920
|
+
for (const e of r.errors) say(red(`✗ ${e}`));
|
|
7921
|
+
if (r.backups) say(dim(` Old copies: ${tilde(r.backups)}`));
|
|
7922
|
+
if (!r.errors.length) say(dim(` ${si.nextStep(plan)}`));
|
|
7923
|
+
console.log("");
|
|
7924
|
+
paintBar();
|
|
7925
|
+
}
|
|
7926
|
+
|
|
7790
7927
|
/**
|
|
7791
7928
|
* /skills-copy-to-claude [--dry-run] [name…] — gu's skills into Claude Code's folders (#241):
|
|
7792
7929
|
* ~/.gonext/skills → ~/.claude/skills and ./.gonext/skills → ./.claude/skills, "gu-" names become
|
|
@@ -14110,7 +14247,10 @@ async function main() {
|
|
|
14110
14247
|
if (isClientExpert()) {
|
|
14111
14248
|
try {
|
|
14112
14249
|
const ceApi = await import("./client-expert-api.mjs");
|
|
14113
|
-
|
|
14250
|
+
// The API this gu is configured for, on ITS port — not the default port, which another
|
|
14251
|
+
// API (or none) may hold (#254: the banner differed between two machines).
|
|
14252
|
+
const port = Number(new URL(apiBase).port) || undefined;
|
|
14253
|
+
apiBannerVersion = String((await ceApi.probeLocalApi(port)).body?.version ?? "").trim();
|
|
14114
14254
|
} catch { /* absent beats guessed — no suffix */ }
|
|
14115
14255
|
}
|
|
14116
14256
|
const statusRows = [
|
|
@@ -14805,6 +14945,13 @@ async function main() {
|
|
|
14805
14945
|
console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
|
|
14806
14946
|
lineRaw = recapQuestion(entries);
|
|
14807
14947
|
}
|
|
14948
|
+
// /resume (#285): show where the last unfinished session stopped; on "yes" the line becomes the
|
|
14949
|
+
// prompt that carries it on, and the turn runs as if it had been typed.
|
|
14950
|
+
if (lineRaw.trim() === "/resume") {
|
|
14951
|
+
const prompt = await runResumeCommand();
|
|
14952
|
+
if (!prompt) continue;
|
|
14953
|
+
lineRaw = prompt;
|
|
14954
|
+
}
|
|
14808
14955
|
const line = lineRaw.trim();
|
|
14809
14956
|
// #302: what was typed for THIS line (before expansion), and whether it was sent from a
|
|
14810
14957
|
// stopped turn's restored question.
|
|
@@ -14992,6 +15139,14 @@ async function main() {
|
|
|
14992
15139
|
await runSwitchSkillCommand(line);
|
|
14993
15140
|
continue;
|
|
14994
15141
|
}
|
|
15142
|
+
if (line === "/skill-pack" || line.startsWith("/skill-pack ")) {
|
|
15143
|
+
await runSkillPackCommand(line);
|
|
15144
|
+
continue;
|
|
15145
|
+
}
|
|
15146
|
+
if (line === "/skill-install" || line.startsWith("/skill-install ")) {
|
|
15147
|
+
await runSkillInstallCommand(line);
|
|
15148
|
+
continue;
|
|
15149
|
+
}
|
|
14995
15150
|
if (line === "/skills-copy-to-claude" || line.startsWith("/skills-copy-to-claude ")) {
|
|
14996
15151
|
await runSkillsCopyCommand(line);
|
|
14997
15152
|
continue;
|
package/hooks-config.mjs
CHANGED
|
@@ -147,7 +147,9 @@ export function cleanHooks(raw, where, source = "gu") {
|
|
|
147
147
|
...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
|
|
148
148
|
...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
|
|
149
149
|
}));
|
|
150
|
-
|
|
150
|
+
// #281: a group marked dialect "claude" (a packed skill's hook, installed for both tools) is
|
|
151
|
+
// spoken to in Claude's vocabulary, as if it had come from Claude Code's settings.
|
|
152
|
+
if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs, ...(g.dialect === "claude" ? { dialect: "claude" } : {}) });
|
|
151
153
|
}
|
|
152
154
|
if (kept.length) hooks[event] = kept;
|
|
153
155
|
}
|
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.885",
|
|
4
4
|
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama, MLX or any OpenAI-compatible provider). macOS, Linux and Windows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -146,6 +146,9 @@
|
|
|
146
146
|
"skills.mjs",
|
|
147
147
|
"skill-source.mjs",
|
|
148
148
|
"skills-copy.mjs",
|
|
149
|
+
"skill-package.mjs",
|
|
150
|
+
"resume-memory.mjs",
|
|
151
|
+
"skill-install.mjs",
|
|
149
152
|
"shell-details.mjs",
|
|
150
153
|
"proc-identity.mjs",
|
|
151
154
|
"win-path.mjs",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gu /resume (#285): where the last unfinished session in this project stopped — Claude Code's or
|
|
3
|
+
* gu's — read from the session-memory skill's database, so gu can carry on Claude's work.
|
|
4
|
+
*
|
|
5
|
+
* The memory skill is found through the hooks this folder uses (its memory_hook.py sits next to
|
|
6
|
+
* memory_cli.py), so whichever copy is installed — ~/.claude/memory, a /skill-install runtime —
|
|
7
|
+
* is the one asked. Nothing here writes the database.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const SKILL = "claude-session-memory-orchestration";
|
|
15
|
+
|
|
16
|
+
/** {python, cli} of the memory skill, or null when it is not installed. */
|
|
17
|
+
export function findMemoryCli({ cwd = process.cwd(), home = homedir(), hooksFor } = {}) {
|
|
18
|
+
for (const source of [undefined, "claude", "gu"]) {
|
|
19
|
+
let hooks = {};
|
|
20
|
+
try {
|
|
21
|
+
hooks = hooksFor ? hooksFor({ cwd, home, source }) : {};
|
|
22
|
+
} catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
for (const groups of Object.values(hooks)) {
|
|
26
|
+
for (const g of groups ?? []) {
|
|
27
|
+
for (const h of g.hooks ?? []) {
|
|
28
|
+
const script = [h.command, ...(h.args ?? [])].find((a) => /memory_hook\.py$/.test(String(a)));
|
|
29
|
+
if (!script) {continue;}
|
|
30
|
+
const cli = join(dirname(dirname(String(script))), "memory_cli.py");
|
|
31
|
+
if (existsSync(cli)) {return { python: String(h.command).endsWith(".py") ? pythonFor(cli) : String(h.command), cli };}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const dir of [join(home, ".gonext", "skill-runtime", SKILL), join(home, ".claude", "skill-runtime", SKILL), join(home, ".claude", "memory")]) {
|
|
37
|
+
const cli = join(dir, "memory_cli.py");
|
|
38
|
+
if (existsSync(cli)) {return { python: pythonFor(cli), cli };}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pythonFor(cli) {
|
|
44
|
+
const dir = dirname(cli);
|
|
45
|
+
for (const p of [join(dir, ".venv", "bin", "python"), join(dir, ".venv", "Scripts", "python.exe")]) {if (existsSync(p)) {return p;}}
|
|
46
|
+
return process.platform === "win32" ? "python" : "python3"; // platform-ok: the venv is preferred above
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** {block, resume} from `memory_cli.py resume --json`, or {error}. */
|
|
50
|
+
export function readResume({ cwd = process.cwd(), found, run = spawnSync } = {}) {
|
|
51
|
+
if (!found) {return { error: "the memory skill is not installed here — /skill-install claude-session-memory-orchestration.zip" };}
|
|
52
|
+
const r = run(found.python, [found.cli, "resume", "--json"], { cwd, encoding: "utf8", timeout: 20000 });
|
|
53
|
+
if (r.error || r.status !== 0) {return { error: `memory_cli resume failed: ${(r.error?.message || r.stderr || "").trim().split("\n").pop()}` };}
|
|
54
|
+
try {
|
|
55
|
+
const d = JSON.parse(r.stdout);
|
|
56
|
+
return { block: d.block || "", resume: d.resume ?? null };
|
|
57
|
+
} catch {
|
|
58
|
+
return { error: "memory_cli resume printed something that is not JSON" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The step to offer: the saved next action, else the first plan step not done. */
|
|
63
|
+
export function nextStepOf(resume) {
|
|
64
|
+
if (!resume) {return "";}
|
|
65
|
+
const open = (resume.plan ?? []).find((p) => p.status !== "completed");
|
|
66
|
+
return String(resume.next_action || open?.text || resume.task?.next_action || "").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The question asked before carrying on. */
|
|
70
|
+
export function resumeQuestion(resume) {
|
|
71
|
+
const step = nextStepOf(resume);
|
|
72
|
+
const what = resume?.task?.id ? `task #${resume.task.id}` : "this work";
|
|
73
|
+
return step ? `Continue ${what} from "${step.length > 60 ? step.slice(0, 59) + "…" : step}"?` : `Continue ${what}?`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What the agent is sent on "yes": the block itself, and what to do with it. */
|
|
77
|
+
export function resumePrompt(block, resume) {
|
|
78
|
+
const step = nextStepOf(resume);
|
|
79
|
+
return [
|
|
80
|
+
`Continue the unfinished work below${resume?.task?.id ? ` (task #${resume.task.id})` : ""}. It was saved by the memory skill${resume?.tool === "claude" ? " from a Claude Code session" : ""}.`,
|
|
81
|
+
step ? `Start from: ${step}. Check the files it lists first; do not redo steps marked ✓.` : "Check the files it lists first; do not redo steps marked ✓.",
|
|
82
|
+
"",
|
|
83
|
+
block,
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|