@macaw-ai/client 0.9.10 → 0.9.11

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 (2) hide show
  1. package/bin/install.js +170 -26
  2. package/package.json +1 -1
package/bin/install.js CHANGED
@@ -65,24 +65,59 @@ const byVersionDesc = (a, b) => {
65
65
 
66
66
  // ---------------------------------------------------------------- python ---
67
67
 
68
- function pythonVersionOf(exe) {
69
- const r = spawnSync(exe, ["-c", "import sys;print('%d.%d' % sys.version_info[:2])"],
68
+ // An interpreter is a command plus leading arguments, because the reliable way
69
+ // to name a version on Windows is the launcher: `py -3.14`. Everything that
70
+ // runs Python here goes through this shape.
71
+ const interp = (cmd, ...pre) => ({ cmd, pre, display: [cmd, ...pre].join(" ") });
72
+
73
+ function pythonVersionOf(py) {
74
+ const r = spawnSync(py.cmd, [...py.pre, "-c", "import sys;print('%d.%d' % sys.version_info[:2])"],
70
75
  { encoding: "utf8" });
71
76
  return r.status === 0 ? r.stdout.trim() : null;
72
77
  }
73
78
 
79
+ // How to ask for a specific version, per platform. Unix names the binary;
80
+ // Windows has no python3.14 on PATH and uses the launcher instead, with bare
81
+ // `python` as the fallback - `python3` there is often a Store alias that is
82
+ // absent or stubbed.
83
+ function candidatesFor(v) {
84
+ const minor = v.split(".")[1];
85
+ return process.platform === "win32"
86
+ ? [interp("py", `-${v}`), interp(`python${v}`), interp("python")]
87
+ : [interp(`python${v}`), interp(`python3.${minor}`)];
88
+ }
89
+
90
+ const defaultInterp = () => (process.platform === "win32" ? interp("python") : interp("python3"));
91
+
74
92
  // Newest first, so a machine with several gets the one we would rather it use.
93
+ //
94
+ // MACAW_PYTHON names an interpreter outright, arguments included, so
95
+ // MACAW_PYTHON="py -3.12" works. That is the way out of the mismatch this
96
+ // preference can cause: on a machine where the default is 3.12 and 3.14 also
97
+ // exists, the library lands where `python3 foo.py` cannot import it.
75
98
  function findPython(wheels) {
76
99
  const wanted = [...new Set(wheels.map((w) => version(w.tag)))].sort(byVersionDesc);
77
100
 
101
+ const named = process.env.MACAW_PYTHON;
102
+ if (named) {
103
+ const py = interp(...named.trim().split(/\s+/));
104
+ const v = pythonVersionOf(py);
105
+ if (!v) return { error: `MACAW_PYTHON=${named} will not run.` };
106
+ if (!wanted.includes(v)) {
107
+ return { error: `MACAW_PYTHON=${named} is Python ${v}. We ship ${wanted.join(", ")}.` };
108
+ }
109
+ return { ...py, version: v };
110
+ }
111
+
78
112
  for (const v of wanted) {
79
- for (const exe of [`python${v}`, `python3.${v.split(".")[1]}`]) {
80
- if (pythonVersionOf(exe) === v) return { exe, version: v };
113
+ for (const py of candidatesFor(v)) {
114
+ if (pythonVersionOf(py) === v) return { ...py, version: v };
81
115
  }
82
116
  }
83
- // Whatever `python3` is may still be one we ship for.
84
- const v = pythonVersionOf("python3");
85
- if (v && wanted.includes(v)) return { exe: "python3", version: v };
117
+ // Whatever the bare interpreter is may still be one we ship for.
118
+ const d = defaultInterp();
119
+ const v = pythonVersionOf(d);
120
+ if (v && wanted.includes(v)) return { ...d, version: v };
86
121
  return null;
87
122
  }
88
123
 
@@ -98,25 +133,81 @@ function findPython(wheels) {
98
133
  // and "STRONGLY recommend that you additionally pass --user". The pair is the
99
134
  // point: --user writes to ~/.local and never into the managed prefix, which is
100
135
  // the thing the error exists to protect.
101
- function pipInstall(python, files) {
136
+ function pipInstall(py, files) {
102
137
  const base = ["-m", "pip", "install", "--disable-pip-version-check", "-q"];
103
138
  const run = (args) =>
104
- spawnSync(python, [...base, ...args, ...files], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
139
+ spawnSync(py.cmd, [...py.pre, ...base, ...args, ...files],
140
+ { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
105
141
 
106
- const inVenv = spawnSync(python, ["-c", "import sys;print(sys.prefix!=sys.base_prefix)"],
142
+ const inVenv = spawnSync(py.cmd, [...py.pre, "-c", "import sys;print(sys.prefix!=sys.base_prefix)"],
107
143
  { encoding: "utf8" }).stdout?.trim() === "True";
108
144
 
145
+ // Windows has no externally-managed interpreters and no ~/.local; --user
146
+ // there writes to %APPDATA%\Python, which is what we want and is the default
147
+ // behaviour, so the wording follows the platform.
148
+ const userDir = process.platform === "win32" ? "your user site-packages" : "~/.local";
149
+
109
150
  let r = run(inVenv ? [] : ["--user"]);
110
- if (r.status === 0) return { ok: true, where: inVenv ? "the active virtualenv" : "~/.local" };
151
+ if (r.status === 0) return { ok: true, where: inVenv ? "the active virtualenv" : userDir };
111
152
 
112
153
  if (/externally-managed-environment/i.test(r.stderr || "")) {
113
- info("this Python is externally managed - installing to ~/.local, which leaves it untouched");
154
+ info(`this Python is externally managed - installing to ${userDir}, which leaves it untouched`);
114
155
  r = run(["--user", "--break-system-packages"]);
115
- if (r.status === 0) return { ok: true, where: "~/.local" };
156
+ if (r.status === 0) return { ok: true, where: userDir };
116
157
  }
117
158
  return { ok: false, err: (r.stderr || r.stdout || "").trim() };
118
159
  }
119
160
 
161
+ // ------------------------------------------------------------------ key ---
162
+
163
+ // Read from four places, write to one. The last two are read and never
164
+ // written: a key already on the machine joins that workspace instead of
165
+ // minting a second, and a config kept in ~/.macaw by hand stays as it is.
166
+ //
167
+ // The current directory is in the list because the coding-agents installer
168
+ // writes $PWD/.macaw/config.json, so `npx macaw-ai install` choosing both
169
+ // surfaces activates once rather than twice.
170
+ function existingKey(dir) {
171
+ // --activate asks for a new workspace outright. Without it the only way to
172
+ // re-key was to delete this install's config, which the next run refills
173
+ // from $HOME or the current directory - so the instruction looped and anyone
174
+ // who had ever used MACAW could not reach activation. Nothing is deleted
175
+ // here; the existing files are simply not read.
176
+ if (process.argv.includes("--activate")) return null;
177
+
178
+ // Handed to us rather than discovered, so there is nothing to confirm. This
179
+ // is how `macaw-ai install` choosing both surfaces passes the key the first
180
+ // installer just created, instead of asking for it a second time.
181
+ const fromEnv = process.env.MACAW_API_KEY;
182
+ if (fromEnv) {
183
+ return {
184
+ key: fromEnv.trim(),
185
+ from: "MACAW_API_KEY",
186
+ workspace: process.env.MACAW_WORKSPACE || "",
187
+ explicit: true,
188
+ };
189
+ }
190
+
191
+ const places = [
192
+ process.env.MACAW_HOME && path.join(process.env.MACAW_HOME, ".macaw", "config.json"),
193
+ path.join(dir, ".macaw", "config.json"),
194
+ path.join(process.cwd(), ".macaw", "config.json"),
195
+ path.join(os.homedir(), ".macaw", "config.json"),
196
+ ].filter(Boolean);
197
+
198
+ for (const p of places) {
199
+ try {
200
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
201
+ if (typeof cfg?.api_key === "string" && cfg.api_key.startsWith("mw_")) {
202
+ return { key: cfg.api_key, from: p, workspace: cfg.workspace || "" };
203
+ }
204
+ } catch {
205
+ // Unreadable or not JSON. Try the next one.
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
120
211
  // ------------------------------------------------------------ activation ---
121
212
 
122
213
  async function activate() {
@@ -183,12 +274,23 @@ async function main() {
183
274
  // Useful before installing, and the first thing to ask when it did not work.
184
275
  if (process.argv.includes("--check")) {
185
276
  const py = findPython(usable);
277
+ const d = defaultInterp();
278
+ const dflt = pythonVersionOf(d);
186
279
  console.log(` platform ${process.platform}/${process.arch} → ${plat}`);
187
280
  const shipped = [...new Set(usable.map((w) => version(w.tag)))].sort(byVersionDesc);
188
281
  console.log(` we ship ${shipped.map((v) => "Python " + v).join(", ")}`);
189
- console.log(py ? ` found ${py.exe} (${py.version})` : ` found nothing we have a build for`);
282
+ console.log(` ${d.display.padEnd(9)} ${dflt || "not found"}`);
283
+ console.log(py?.error ? ` would use ${py.error}`
284
+ : py ? ` would use ${py.display} (${py.version})`
285
+ : ` would use nothing we have a build for`);
286
+ if (py && !py.error && dflt && dflt !== py.version) {
287
+ console.log(` ${C.y}⚠${C.x} your ${d.display} cannot import what that installs.`);
288
+ console.log(` MACAW_PYTHON=${d.display} to install into it instead`);
289
+ }
290
+ const haveKey = existingKey(path.resolve("./macaw"));
291
+ console.log(` key ${haveKey ? "reusing " + haveKey.from : "none found; would activate"}`);
190
292
  console.log(` endpoint ${API}`);
191
- process.exit(py ? 0 : 1);
293
+ process.exit(py && !py.error ? 0 : 1);
192
294
  }
193
295
 
194
296
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -206,22 +308,49 @@ async function main() {
206
308
 
207
309
  // 2 — python
208
310
  const py = findPython(usable);
209
- if (!py) {
210
- no("No Python here that we have a build for.");
311
+ if (!py || py.error) {
312
+ no(py?.error || "No Python here that we have a build for.");
211
313
  info(`On ${plat} we ship: ${[...new Set(usable.map((w) => version(w.tag)))].sort(byVersionDesc).join(", ")}`);
212
314
  rl.close();
213
315
  process.exit(1);
214
316
  }
215
- ok(`Python ${py.version}`);
216
- const newest = usable.map((w) => version(w.tag)).sort(byVersionDesc)[0];
217
- if (py.version !== newest) {
218
- console.log(` ${C.y}⚠${C.x} We are moving to Python ${newest}.`);
317
+ ok(`Python ${py.version} (${py.display})`);
318
+
319
+ // We prefer the newest interpreter we have a wheel for, which is not always
320
+ // the one a bare `python3` resolves to. When they differ the install succeeds
321
+ // and the user's default interpreter still cannot import the library, so say
322
+ // so here rather than letting them find out from a traceback.
323
+ const dflt = defaultInterp();
324
+ const dfltVersion = pythonVersionOf(dflt);
325
+ if (dfltVersion && dfltVersion !== py.version) {
326
+ console.log(` ${C.y}⚠${C.x} Your ${dflt.display} is ${dfltVersion}; this installs into Python ${py.version}.`);
327
+ console.log(` Run MACAW code with ${C.b}${py.display}${C.x}, or install into ${dflt.display} instead:`);
328
+ console.log(` ${C.c}MACAW_PYTHON=${dflt.display} npx @macaw-ai/client install${C.x}`);
219
329
  }
220
330
 
221
331
  // 3 — a key, or a workspace
222
332
  console.log("");
223
- let key = await ask(` API Key ${C.dim}[Enter creates a new workspace]${C.x} `, "");
224
- let workspace = "";
333
+ const found = existingKey(dir);
334
+ let workspace = found?.workspace || "";
335
+ let key;
336
+
337
+ if (found?.explicit) {
338
+ key = found.key;
339
+ ok(found.workspace ? `Same workspace: ${found.workspace}` : "Using the key passed in");
340
+ } else if (found) {
341
+ info(`A key is already on this machine: ${found.from}`);
342
+ key = await ask(` API Key ${C.dim}[Enter joins that workspace]${C.x} `, found.key);
343
+ } else if (!process.stdin.isTTY) {
344
+ // Without this the default is "", which starts a device activation,
345
+ // prints a link nobody is watching, and blocks for ten minutes.
346
+ no("No key found, and nothing is attached to approve a new workspace.");
347
+ info("Run this in a terminal, or pass a key: MACAW_API_KEY=mw_... npx @macaw-ai/client install");
348
+ rl.close();
349
+ process.exit(1);
350
+ } else {
351
+ key = await ask(` API Key ${C.dim}[Enter creates a new workspace]${C.x} `, "");
352
+ }
353
+
225
354
  if (key === "") {
226
355
  const got = await activate();
227
356
  if (!got) { rl.close(); process.exit(1); }
@@ -242,9 +371,21 @@ async function main() {
242
371
  ? fs.readdirSync(path.join(PKG, "wheels")).filter((f) => f.startsWith("macaw_adapters-"))
243
372
  .map((f) => path.join(PKG, "wheels", f))
244
373
  : [];
245
- const r = pipInstall(py.exe, [wheel.file, ...adapters]);
374
+ const r = pipInstall(py, [wheel.file, ...adapters]);
246
375
  if (!r.ok) { no("pip install failed"); console.log(r.err); process.exit(1); }
247
- ok(`macaw-client and macaw-adapters installed into ${r.where}`);
376
+
377
+ // pip exiting 0 does not mean the library is importable: a --user install
378
+ // whose site directory is not on this interpreter's sys.path passes, and so
379
+ // does one shadowed by an older copy earlier on the path. Ask the question
380
+ // the user will ask.
381
+ const imported = spawnSync(py.cmd, [...py.pre, "-c", "import macaw_client, macaw_adapters"],
382
+ { encoding: "utf8" });
383
+ if (imported.status !== 0) {
384
+ no(`pip reported success, but ${py.display} cannot import what it installed.`);
385
+ console.log((imported.stderr || imported.stdout || "").trim());
386
+ process.exit(1);
387
+ }
388
+ ok(`macaw-client and macaw-adapters installed into ${r.where}, importable by ${py.display}`);
248
389
 
249
390
  // 5 — the tree, and the config beside it
250
391
  // Whatever build.sh assembled, verbatim. A list here would be a second
@@ -253,6 +394,9 @@ async function main() {
253
394
  if (fs.existsSync(payload)) fs.cpSync(payload, dir, { recursive: true });
254
395
  const cfgDir = path.join(dir, ".macaw");
255
396
  fs.mkdirSync(cfgDir, { recursive: true, mode: 0o700 });
397
+ // The config holds an API key and the install directory may be inside a
398
+ // repository.
399
+ fs.writeFileSync(path.join(cfgDir, ".gitignore"), "*\n!.gitignore\n");
256
400
  fs.writeFileSync(path.join(cfgDir, "config.json"), JSON.stringify({
257
401
  transport: "http",
258
402
  http_enabled: true,
@@ -267,7 +411,7 @@ async function main() {
267
411
  console.log("");
268
412
  console.log(`${C.b} Check it${C.x}`);
269
413
  console.log(` cd ${dir}`);
270
- console.log(` ${py.exe} secureAI/test_harness_quick.py`);
414
+ console.log(` ${py.display} secureAI/test_harness_quick.py`);
271
415
  console.log("");
272
416
  console.log(` ${C.dim}To use MACAW from other directories, point MACAW_HOME at${C.x}`);
273
417
  console.log(` ${C.dim}this directory in your shell:${C.x}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@macaw-ai/client",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "description": "Set up the MACAW client for a Python project: installs the SDK and adapters, creates a workspace, leaves the examples ready to run.",
5
5
  "type": "module",
6
6
  "bin": {