@cooked-ham/hamgoose 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +11 -8
  2. package/bin/hamgoose.mjs +238 -151
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,14 +15,15 @@ dependency-free launcher so the install works from the Node world too. It finds
15
15
  ## Usage
16
16
 
17
17
  ```bash
18
- # install the Python package (idempotent; finds Python 3.11+, falls back to `py -3`)
19
- npx @cooked-ham/hamgoose install
20
-
21
- # install + register with Goose's config in one shot
22
- npx @cooked-ham/hamgoose register
23
-
24
- # run the MCP stdio server (what Goose spawns)
25
- npx -y @cooked-ham/hamgoose
18
+ # either globally
19
+ npm i -g @cooked-ham/hamgoose
20
+ hamgoose register # install the Python package + register with Goose
21
+ hamgoose help # show all commands
22
+
23
+ # …or ad-hoc via npx
24
+ npx @cooked-ham/hamgoose install # install the Python package (idempotent)
25
+ npx @cooked-ham/hamgoose register # install + register with Goose's config
26
+ npx -y @cooked-ham/hamgoose # run the MCP stdio server (what Goose spawns)
26
27
  ```
27
28
 
28
29
  ## Use it as a Goose extension
@@ -34,6 +35,8 @@ npx -y @cooked-ham/hamgoose
34
35
  npx -y @cooked-ham/hamgoose
35
36
  ```
36
37
 
38
+ (If you did `npm i -g @cooked-ham/hamgoose`, the command is simply `hamgoose`.)
39
+
37
40
  …or the equivalent `extensions:` entry in `config.yaml`. Then in any repo:
38
41
  `goose` → `/start_mission`.
39
42
 
package/bin/hamgoose.mjs CHANGED
@@ -1,152 +1,239 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * @cooked-ham/hamgoose — npm launcher for the hamgoose Goose extension.
4
- *
5
- * hamgoose itself is a Python stdio MCP server (github.com/cooked-ham/hamgoose).
6
- * This package is a thin, dependency-free launcher with one job: make
7
- * `npx @cooked-ham/hamgoose` just work. It never contains mission logic.
8
- *
9
- * npx -y @cooked-ham/hamgoose run the MCP stdio server (what Goose spawns)
10
- * npx @cooked-ham/hamgoose install install the Python package (idempotent)
11
- * npx @cooked-ham/hamgoose register install + register with Goose
12
- * npx @cooked-ham/hamgoose --version print version
13
- */
14
- import { spawn, spawnSync } from "node:child_process";
15
- import { existsSync } from "node:fs";
16
- import process from "node:process";
17
- import path from "node:path";
18
- import { createRequire } from "node:module";
19
-
20
- const REPO = "https://github.com/cooked-ham/hamgoose.git";
21
- const WIN = process.platform === "win32";
22
- const require = createRequire(import.meta.url);
23
- const VERSION = require("../package.json").version;
24
-
25
- /** Run a command, capture output. cmd may be a "tool -flag" string (Windows). */
26
- function run(cmd, args = [], inherit = false) {
27
- const r = spawnSync(cmd, args, {
28
- encoding: "utf8",
29
- shell: WIN,
30
- stdio: inherit ? "inherit" : "pipe",
31
- });
32
- return { code: r.status ?? 1, out: ((r.stdout || "") + (r.stderr || "")).trim() };
33
- }
34
-
35
- function which(name) {
36
- const r = WIN
37
- ? run("where", [name])
38
- : run("sh", ["-c", `command -v ${name}`]);
39
- if (r.code !== 0) return null;
40
- const line = r.out.split(/\r?\n/).find(Boolean);
41
- return line || null;
42
- }
43
-
44
- function pythonCandidates() {
45
- return WIN ? ["py -3", "python", "python3"] : ["python3", "python"];
46
- }
47
-
48
- /** Find a Python >= 3.11 command string, or null. */
49
- function findPython() {
50
- for (const py of pythonCandidates()) {
51
- const ok = run(py, [
52
- "-c",
53
- "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)",
54
- ]);
55
- if (ok.code === 0) return py;
56
- }
57
- return null;
58
- }
59
-
60
- /** pip-install the Python package (idempotent). Returns success bool. */
61
- function pipInstall(py) {
62
- console.log(`Installing hamgoose from ${REPO} …`);
63
- let r = run(py, ["-m", "pip", "install", "--user", REPO], true);
64
- if (r.code !== 0 && /externally managed/i.test(r.out)) {
65
- console.log("Retrying with --break-system-packages (PEP 668)");
66
- r = run(py, ["-m", "pip", "install", "--user", "--break-system-packages", REPO], true);
67
- }
68
- if (r.code !== 0) {
69
- console.error("\nInstall failed. Manual fallback:\n " +
70
- ` ${py} -m pip install ${REPO}\n` +
71
- " (git must be on PATH for the git+ URL; see the repo README for a zip fallback)");
72
- return false;
73
- }
74
- return true;
75
- }
76
-
77
- /** Locate the installed `hamgoose` console script. Returns full path or null. */
78
- function resolveServer(py) {
79
- const onPath = which("hamgoose");
80
- if (onPath) return onPath;
81
- if (py) {
82
- const base = run(py, ["-m", "site", "--user-base"]);
83
- if (base.code === 0) {
84
- const p = WIN
85
- ? path.join(base.out.trim(), "Scripts", "hamgoose.exe")
86
- : path.join(base.out.trim(), "bin", "hamgoose");
87
- if (existsSync(p)) return p;
88
- }
89
- }
90
- return null;
91
- }
92
-
93
- /** Ensure the Python package is installed. Returns server path or null. */
94
- function ensureInstalled(quiet = false) {
95
- const py = findPython();
96
- let server = resolveServer(py);
97
- if (server) {
98
- if (!quiet) console.log(`hamgoose already installed (${server})`);
99
- return server;
100
- }
101
- if (!py) {
102
- console.error("Python 3.11+ not found. Install one (python.org or `uv`) and retry —\n" +
103
- " or: uv python install 3.12 && uv tool install git+" + REPO);
104
- return null;
105
- }
106
- if (!pipInstall(py)) return null;
107
- server = resolveServer(py);
108
- if (!quiet) console.log(server ? `Installed: ${server}` : "Installed (add your Python Scripts dir to PATH, or rerun).");
109
- return server;
110
- }
111
-
112
- function printHelp() {
113
- console.log(`hamgoose npm launcher v${VERSION}
114
- Runs the Python hamgoose server (github.com/cooked-ham/hamgoose).
115
-
116
- Usage:
117
- npx -y @cooked-ham/hamgoose run the MCP stdio server (what Goose spawns)
118
- npx @cooked-ham/hamgoose install install the Python package (idempotent)
119
- npx @cooked-ham/hamgoose register install + register with Goose's config
120
- npx @cooked-ham/hamgoose --version print version
121
-
122
- Use with Goose: Add Extension (STDIO), command:
123
- npx -y @cooked-ham/hamgoose`);
124
- }
125
-
126
- const [, , ...args] = process.argv;
127
-
128
- if (args.includes("--help") || args.includes("-h")) {
129
- printHelp();
130
- process.exit(0);
131
- }
132
- if (args.includes("--version")) {
133
- console.log(VERSION);
134
- process.exit(0);
135
- }
136
- if (args[0] === "install") {
137
- process.exit(ensureInstalled(false) ? 0 : 1);
138
- }
139
- if (args[0] === "register") {
140
- const py = findPython();
141
- const server = ensureInstalled(false) || resolveServer(py);
142
- if (!server) process.exit(1);
143
- console.log("Registering with Goose…");
144
- const r = spawnSync(server, ["register"], { stdio: "inherit", shell: WIN });
145
- process.exit(r.status ?? 1);
146
- }
147
-
148
- // Default: stdio server mode (Goose's extension command).
149
- const server = ensureInstalled(true);
150
- if (!server) process.exit(1);
151
- const child = spawn(server, [], { stdio: "inherit", shell: WIN });
152
- child.on("exit", (code) => process.exit(code ?? 0));
2
+ /**
3
+ * @cooked-ham/hamgoose — npm launcher for the hamgoose Goose extension.
4
+ *
5
+ * hamgoose itself is a Python stdio MCP server (github.com/cooked-ham/hamgoose).
6
+ * This package is a thin, dependency-free launcher with one job: make
7
+ * `npx @cooked-ham/hamgoose` (or a globally installed `hamgoose`) just work.
8
+ * It never contains mission logic.
9
+ *
10
+ * hamgoose run the MCP stdio server (what Goose spawns)
11
+ * hamgoose install install the Python package (idempotent)
12
+ * hamgoose register install + register with Goose
13
+ * hamgoose unregister remove from Goose's config
14
+ * hamgoose help show help
15
+ * hamgoose --version print version
16
+ *
17
+ * Design notes (fix for 0.1.0 "infinite wall" bug):
18
+ * - The npm bin is named `hamgoose`, the SAME as the Python console script.
19
+ * Resolving "the server" via `where hamgoose` therefore finds THIS launcher's
20
+ * own global shim first and spawns itself forever. So the preferred
21
+ * resolution is `python -m hamgoose` (verified by importing the module with
22
+ * a known interpreter — PATH is never consulted), and any PATH hit that
23
+ * resolves back to this package is rejected (isOurOwn()).
24
+ * - On Windows we spawn a single quoted command line with an EMPTY args array.
25
+ * shell:true + args is deprecated (node DEP0190) and unsafe.
26
+ */
27
+ import { spawn, spawnSync } from "node:child_process";
28
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
29
+ import process from "node:process";
30
+ import path from "node:path";
31
+ import { createRequire } from "node:module";
32
+
33
+ const REPO = "https://github.com/cooked-ham/hamgoose.git";
34
+ const WIN = process.platform === "win32";
35
+ const require = createRequire(import.meta.url);
36
+ const VERSION = require("../package.json").version;
37
+ // This launcher's own package dir (…/node_modules/@cooked-ham/hamgoose, or the
38
+ // repo's npm/ dir when run from a checkout). Anything resolving inside here is
39
+ // us, not the Python server.
40
+ const PKG_ROOT = path.dirname(require.resolve("../package.json"));
41
+
42
+ /** Quote one token for cmd.exe. */
43
+ function q(token) {
44
+ const t = String(token);
45
+ return /[ \t"]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t;
46
+ }
47
+
48
+ /**
49
+ * Run a command, capture output.
50
+ * Windows: build ONE quoted command line and pass no args (shell:true + args
51
+ * triggers DEP0190 and is unsafe). POSIX: plain exec with args, no shell.
52
+ */
53
+ function run(cmd, args = [], inherit = false) {
54
+ const stdio = inherit ? "inherit" : "pipe";
55
+ const r = WIN
56
+ ? spawnSync([cmd, ...args].map(q).join(" "), [], { encoding: "utf8", shell: true, stdio })
57
+ : spawnSync(cmd, args, { encoding: "utf8", stdio });
58
+ return { code: r.status ?? 1, out: ((r.stdout || "") + (r.stderr || "")).trim() };
59
+ }
60
+
61
+ /** Spawn the server the same safe way. sync=true returns a SpawnSync result. */
62
+ function launch(cmdArgs, extra = [], sync = false, env = process.env) {
63
+ const all = [cmdArgs[0], ...cmdArgs[1], ...extra];
64
+ if (WIN) {
65
+ const line = all.map(q).join(" ");
66
+ return sync
67
+ ? spawnSync(line, [], { stdio: "inherit", shell: true, env })
68
+ : spawn(line, [], { stdio: "inherit", shell: true, env });
69
+ }
70
+ return sync
71
+ ? spawnSync(all[0], all.slice(1), { stdio: "inherit", env })
72
+ : spawn(all[0], all.slice(1), { stdio: "inherit", env });
73
+ }
74
+
75
+ /** True if p points at THIS launcher (npm shim / our mjs), not the Python server. */
76
+ function isOurOwn(p) {
77
+ try {
78
+ let rp = path.resolve(p);
79
+ try { rp = realpathSync(rp); } catch { /* keep resolved path */ }
80
+ if (rp.startsWith(PKG_ROOT + path.sep)) return true; // inside this package
81
+ const base = path.basename(rp).toLowerCase();
82
+ if (base === "hamgoose.cmd" || base === "hamgoose.ps1") return true; // npm shims
83
+ if (!path.extname(base)) { // bare `hamgoose` script
84
+ try {
85
+ const txt = readFileSync(rp, "utf8");
86
+ if (txt.length < 8192 && /hamgoose\.mjs|@cooked-ham\/hamgoose/.test(txt)) return true;
87
+ } catch { /* unreadable → not ours */ }
88
+ }
89
+ } catch { /* never treat a failure as "not ours" in a way that matters */ }
90
+ return false;
91
+ }
92
+
93
+ function which(name) {
94
+ const r = WIN
95
+ ? run("where", [name])
96
+ : run("sh", ["-c", `command -v ${name}`]);
97
+ if (r.code !== 0) return null;
98
+ // Skip hits that are this launcher's own bin — the npm global `hamgoose`
99
+ // shim shadows the Python `hamgoose` console script (same name!).
100
+ const line = r.out.split(/\r?\n/).map((s) => s.trim()).find((l) => l && !isOurOwn(l));
101
+ return line || null;
102
+ }
103
+
104
+ function pythonCandidates() {
105
+ return WIN ? ["py -3", "python", "python3"] : ["python3", "python"];
106
+ }
107
+
108
+ /** Find a Python >= 3.11 command string, or null. */
109
+ function findPython() {
110
+ for (const py of pythonCandidates()) {
111
+ const ok = run(py, [
112
+ "-c",
113
+ "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)",
114
+ ]);
115
+ if (ok.code === 0) return py;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** pip-install the Python package (idempotent). Returns success bool. */
121
+ function pipInstall(py) {
122
+ console.log(`Installing hamgoose from ${REPO} …`);
123
+ let r = run(py, ["-m", "pip", "install", "--user", REPO], true);
124
+ if (r.code !== 0 && /externally managed/i.test(r.out)) {
125
+ console.log("Retrying with --break-system-packages (PEP 668)…");
126
+ r = run(py, ["-m", "pip", "install", "--user", "--break-system-packages", REPO], true);
127
+ }
128
+ if (r.code !== 0) {
129
+ console.error("\nInstall failed. Manual fallback:\n " +
130
+ ` ${py} -m pip install ${REPO}\n` +
131
+ " (git must be on PATH for the git+ URL; see the repo README for a zip fallback)");
132
+ return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Locate how to run the Python hamgoose server. Returns [cmd, args] or null.
139
+ *
140
+ * Order matters: the interpreter-checked `py -m hamgoose` first, because the
141
+ * PATH fallback can never see the npm global shim that shadows the name.
142
+ */
143
+ function resolveServer(py) {
144
+ // 1) The interpreter we found can import hamgoose `py -m hamgoose`.
145
+ if (py && run(py, ["-c", "import hamgoose"]).code === 0) return [py, ["-m", "hamgoose"]];
146
+ // 2) A hamgoose console script on PATH that is not this launcher.
147
+ const onPath = which("hamgoose");
148
+ if (onPath && !isOurOwn(onPath)) return [onPath, []];
149
+ // 3) pip --user Scripts/bin fallback.
150
+ if (py) {
151
+ const base = run(py, ["-m", "site", "--user-base"]);
152
+ if (base.code === 0) {
153
+ const p = WIN
154
+ ? path.join(base.out.trim(), "Scripts", "hamgoose.exe")
155
+ : path.join(base.out.trim(), "bin", "hamgoose");
156
+ if (existsSync(p)) return [p, []];
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function describe(cmdArgs) {
163
+ return cmdArgs.length > 1 ? `${cmdArgs[0]} ${cmdArgs[1].join(" ")}` : cmdArgs[0];
164
+ }
165
+
166
+ /** Ensure the Python package is installed. Returns [cmd, args] or null. */
167
+ function ensureInstalled(quiet = false) {
168
+ const py = findPython();
169
+ let server = resolveServer(py);
170
+ if (server) {
171
+ if (!quiet) console.log(`hamgoose already installed (${describe(server)})`);
172
+ return server;
173
+ }
174
+ if (!py) {
175
+ console.error("Python 3.11+ not found. Install one (python.org or `uv`) and retry —\n" +
176
+ " or: uv python install 3.12 && uv tool install git+" + REPO);
177
+ return null;
178
+ }
179
+ if (!pipInstall(py)) return null;
180
+ server = resolveServer(py);
181
+ if (!quiet) console.log(server ? `Installed: ${describe(server)}` : "Installed (rerun, or check your Python install).");
182
+ return server;
183
+ }
184
+
185
+ function printHelp() {
186
+ console.log(`hamgoose npm launcher v${VERSION}
187
+ Runs the Python hamgoose server (github.com/cooked-ham/hamgoose).
188
+
189
+ Usage (after \`npm i -g @cooked-ham/hamgoose\`, or via \`npx -y @cooked-ham/hamgoose\`):
190
+ hamgoose run the MCP stdio server (what Goose spawns)
191
+ hamgoose install install the Python package (idempotent)
192
+ hamgoose register install + register with Goose's config.yaml
193
+ hamgoose unregister remove hamgoose from Goose's config.yaml
194
+ hamgoose help show this message
195
+ hamgoose --version print version
196
+
197
+ Use with Goose: Add Extension (STDIO), Name "hamgoose", Command:
198
+ hamgoose`);
199
+ }
200
+
201
+ const [, , ...args] = process.argv;
202
+ const first = args[0];
203
+
204
+ if (first === "help" || first === "--help" || first === "-h") {
205
+ printHelp();
206
+ process.exit(0);
207
+ }
208
+ if (first === "--version" || first === "-v") {
209
+ console.log(VERSION);
210
+ process.exit(0);
211
+ }
212
+ if (first === "install") {
213
+ process.exit(ensureInstalled(false) ? 0 : 1);
214
+ }
215
+ if (first === "register" || first === "add" || first === "unregister" || first === "remove") {
216
+ const server = ensureInstalled(false);
217
+ if (!server) process.exit(1);
218
+ const pyCmd = first === "add" ? "register" : first === "remove" ? "unregister" : first;
219
+ console.log(pyCmd === "register" ? "Registering with Goose…" : "Updating Goose's config…");
220
+ const r = launch(server, [pyCmd, ...args.slice(1)], true);
221
+ process.exit(r.status ?? 1);
222
+ }
223
+ if (first) {
224
+ // Anything else is NOT stdio-server mode: don't spawn anything.
225
+ console.error(`Unknown command: ${first}\n\n`);
226
+ printHelp();
227
+ process.exit(2);
228
+ }
229
+
230
+ // No args: stdio server mode (Goose's extension command).
231
+ if (process.env.HAMGOOSE_LAUNCHER) {
232
+ console.error("error: hamgoose launcher recursion guard tripped — refusing to spawn. " +
233
+ "Check that `where hamgoose` / `which hamgoose` is not resolving to this launcher.");
234
+ process.exit(3);
235
+ }
236
+ const server = ensureInstalled(true);
237
+ if (!server) process.exit(1);
238
+ const child = launch(server, [], false, { ...process.env, HAMGOOSE_LAUNCHER: "1" });
239
+ child.on("exit", (code) => process.exit(code ?? 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cooked-ham/hamgoose",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "npm launcher for hamgoose — Factory-Droid-style Mission orchestration extension for Goose (goal → plan → approve → isolated workers → validated code)",
5
5
  "keywords": [
6
6
  "goose",