wizards 0.6.2 → 0.7.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 (88) hide show
  1. package/README.md +6 -2
  2. package/apps/runtime/dist/agents/architect.js +7 -29
  3. package/apps/runtime/dist/agents/studio-assistant.js +7 -24
  4. package/apps/runtime/dist/cli/autostart.js +58 -12
  5. package/apps/runtime/dist/cli/autoupdate.js +221 -0
  6. package/apps/runtime/dist/cli/connect.js +6 -2
  7. package/apps/runtime/dist/cli/environment.js +5 -0
  8. package/apps/runtime/dist/cli/machine.js +7 -3
  9. package/apps/runtime/dist/cli/main.js +10 -3
  10. package/apps/runtime/dist/cli/menu.js +27 -8
  11. package/apps/runtime/dist/cli/processes.js +72 -0
  12. package/apps/runtime/dist/cli/setup.js +26 -26
  13. package/apps/runtime/dist/cli/start.js +18 -1
  14. package/apps/runtime/dist/cli/status.js +51 -17
  15. package/apps/runtime/dist/cli/uninstall-main.js +20 -0
  16. package/apps/runtime/dist/cli/uninstall.js +231 -0
  17. package/apps/runtime/dist/cli/update.js +25 -2
  18. package/apps/runtime/dist/cli/versions.js +106 -0
  19. package/apps/runtime/dist/docs/user/command-line.md +3 -2
  20. package/apps/runtime/dist/docs/user/data.md +1 -1
  21. package/apps/runtime/dist/docs/user/install.md +29 -6
  22. package/apps/runtime/dist/engine/input.js +4 -5
  23. package/apps/runtime/dist/engine/steps.js +3 -3
  24. package/apps/runtime/dist/engine/template.js +8 -13
  25. package/apps/runtime/dist/env.js +3 -20
  26. package/apps/runtime/dist/features.js +2 -2
  27. package/apps/runtime/dist/index.js +3 -1
  28. package/apps/runtime/dist/manage.js +7 -0
  29. package/apps/runtime/dist/models.js +12 -0
  30. package/apps/runtime/dist/sandbox/docker.js +0 -2
  31. package/apps/runtime/dist/sandbox/index.js +3 -10
  32. package/apps/runtime/dist/services/space-categories.js +3 -25
  33. package/apps/runtime/dist/tools/browser.js +2 -14
  34. package/apps/runtime/dist/tools/index.js +1 -19
  35. package/apps/runtime/dist/tools/mail.js +1 -4
  36. package/apps/runtime/dist/tools/shared.js +30 -3
  37. package/apps/runtime/dist/update.js +10 -19
  38. package/apps/web/dist/assets/AboutDialog-BJALish1.js +1 -0
  39. package/apps/web/dist/assets/{ChangelogDialog-BRiSYyIn.js → ChangelogDialog-CxmHNvq1.js} +1 -1
  40. package/apps/web/dist/assets/ChatView-C9ASPVJj.js +10 -0
  41. package/apps/web/dist/assets/EditorPage-BHhNEiKa.js +3 -0
  42. package/apps/web/dist/assets/{FlowPreview-CUOEzoeJ.js → FlowPreview-DvpjuQdC.js} +1 -1
  43. package/apps/web/dist/assets/{Harness-D3kTVsF5.js → Harness-Duq0wpfg.js} +2 -2
  44. package/apps/web/dist/assets/{NewWizardChannels-C3PGe_Zd.js → NewWizardChannels-CGFy67jk.js} +1 -1
  45. package/apps/web/dist/assets/NewWizardPage-DwBcRWJj.js +7 -0
  46. package/apps/web/dist/assets/{OnboardingPage-pOPB5TVZ.js → OnboardingPage-YwKzupPI.js} +1 -1
  47. package/apps/web/dist/assets/ProjectPage-DP1xMjbe.js +1 -0
  48. package/apps/web/dist/assets/PublicRunner-CECAuP0g.js +1 -0
  49. package/apps/web/dist/assets/{ReadOnly-CCxZvYrT.js → ReadOnly-BnnGcLWU.js} +1 -1
  50. package/apps/web/dist/assets/RunDrawer-C0vf8QAc.js +4 -0
  51. package/apps/web/dist/assets/{RunnerView-pKik3sdE.js → RunnerView-CBrZpjt5.js} +1 -1
  52. package/apps/web/dist/assets/SettingsPage-C0-mVcs5.js +1 -0
  53. package/apps/web/dist/assets/SetupPage-BGlYQREU.js +1 -0
  54. package/apps/web/dist/assets/{SharePage-CZQqp5JM.js → SharePage-IwxlV6WC.js} +1 -1
  55. package/apps/web/dist/assets/changelog-CPNPseoQ.js +1 -0
  56. package/apps/web/dist/assets/{esm-D4pSRQgT.js → esm-6TNBQNCD.js} +1 -1
  57. package/apps/web/dist/assets/index-DU-Onx-c.js +14 -0
  58. package/apps/web/dist/assets/{lock-DYnHCyRg.js → lock-HLZib0u9.js} +1 -1
  59. package/apps/web/dist/assets/{phone-DJawY1RF.js → phone-DsxbIcWi.js} +1 -1
  60. package/apps/web/dist/assets/settings-menu-BMMkIVP1.js +1 -0
  61. package/apps/web/dist/assets/ui-Brg2qM1A.js +137 -0
  62. package/apps/web/dist/assets/{upload-DL6NRa4h.js → upload-BaGy0ppG.js} +1 -1
  63. package/apps/web/dist/index.html +2 -2
  64. package/apps/web/dist/install.sh +30 -12
  65. package/apps/web/dist/mcp/flow.html +11 -11
  66. package/bin/wizards.mjs +17 -3
  67. package/node_modules/@engenty-wizards/shared/dist/definition.js +6 -17
  68. package/node_modules/@engenty-wizards/shared/dist/features.d.ts +6 -0
  69. package/node_modules/@engenty-wizards/shared/dist/features.js +12 -0
  70. package/node_modules/@engenty-wizards/shared/dist/numbers.d.ts +24 -0
  71. package/node_modules/@engenty-wizards/shared/dist/numbers.js +54 -0
  72. package/node_modules/@engenty-wizards/shared/dist/store.d.ts +7 -0
  73. package/node_modules/@engenty-wizards/shared/dist/store.js +28 -0
  74. package/package.json +1 -1
  75. package/apps/runtime/dist/sandbox/agentos.js +0 -191
  76. package/apps/web/dist/assets/AboutDialog-BrfbtjmR.js +0 -1
  77. package/apps/web/dist/assets/ChatView-_dYvuuUY.js +0 -10
  78. package/apps/web/dist/assets/EditorPage-CkRjTRMg.js +0 -3
  79. package/apps/web/dist/assets/NewWizardPage-B3ol4K43.js +0 -7
  80. package/apps/web/dist/assets/ProjectPage-Boyptzz_.js +0 -1
  81. package/apps/web/dist/assets/PublicRunner-DcETj9_E.js +0 -1
  82. package/apps/web/dist/assets/RunDrawer-hwruVTDG.js +0 -4
  83. package/apps/web/dist/assets/SettingsPage-BOe6NgFg.js +0 -1
  84. package/apps/web/dist/assets/SetupPage-81QxPnfE.js +0 -1
  85. package/apps/web/dist/assets/changelog-CtyrKHAc.js +0 -1
  86. package/apps/web/dist/assets/index-DnIvQFIg.js +0 -14
  87. package/apps/web/dist/assets/settings-menu-B_-_EAmK.js +0 -1
  88. package/apps/web/dist/assets/ui-rh8TaWvT.js +0 -137
package/README.md CHANGED
@@ -159,8 +159,12 @@ when a newer release is out and can run the same update from there or from its A
159
159
  From source: `git pull`, `pnpm install`, `pnpm build`. Your data is brought up to date at the
160
160
  next start.
161
161
 
162
- To remove engenty wizards, run `wizards stop`, `wizards autostart off` and `wizards disconnect`, then delete `~/.engenty/wizards` (your data is in its `data` folder) and
163
- `~/.local/bin/wizards`.
162
+ To remove engenty wizards, run `wizards uninstall` (or `npx wizards uninstall` if the command no
163
+ longer runs). It shows what it will remove first: your AI apps' entries, the login item, every
164
+ running process, `~/.engenty/wizards` and `~/.local/bin/wizards`. Your data in
165
+ `~/.engenty/wizards/data` is kept unless you choose to delete it, and a reinstall picks it up
166
+ again. `--dry-run` only shows the plan, `--yes` runs it without questions and keeps your data,
167
+ and `--yes --delete-data` deletes your data too.
164
168
 
165
169
  ## Run it on a server
166
170
 
@@ -7,16 +7,16 @@ import { z } from "zod";
7
7
  import { connectorsLine, exampleWizard, mcpServersLine, PRINCIPLES, pluginToolsLine, SCHEMA_DOC, WIDGET_GUIDE, } from "../authoring/guide.js";
8
8
  import { wizardOpSchema } from "../authoring/ops.js";
9
9
  import { importFromRegistry, listConnectors, searchRegistry } from "../connectors/external.js";
10
- import { attachTools, costOf, gatewayTools, textModel } from "../models.js";
10
+ import { agentModel, attachTools, costOf, gatewayTools, textModel, totalUsage, } from "../models.js";
11
11
  import { pluginToolsOf } from "../plugins/registry.js";
12
12
  import { htmlToMarkdown } from "../render/convert.js";
13
- import { ServiceError } from "../services/errors.js";
14
13
  import { deleteFile, listFiles, readFileText, writeFile } from "../services/files.js";
15
14
  import { checkDraftWidget } from "../services/widgets.js";
16
15
  import { editWizard, ownedWizard, writeDraft } from "../services/wizards.js";
17
16
  import { currentTenant } from "../tenants/tenant.js";
18
17
  import { serverText } from "../texts.js";
19
18
  import { assertPublicUrl, safeFetch } from "../tools/net-guard.js";
19
+ import { attempt as attemptTool, imageOutput } from "../tools/shared.js";
20
20
  import { runScript } from "../widgets/script.js";
21
21
  import { thoughtLine } from "./thought.js";
22
22
  const MAX_STEPS = 45;
@@ -93,18 +93,8 @@ function activity(tool, args, lang = "en") {
93
93
  return null;
94
94
  }
95
95
  }
96
- /** A tool failure the model can read and fix, instead of a crashed turn. */
97
- async function attempt(run) {
98
- try {
99
- return await run();
100
- }
101
- catch (err) {
102
- if (err instanceof ServiceError) {
103
- return { error: err.message, ...err.data };
104
- }
105
- return { error: err.message };
106
- }
107
- }
96
+ /** A tool failure the model can read and fix, instead of a crashed turn: the whole message. */
97
+ const attempt = (run) => attemptTool(run, { limit: Number.POSITIVE_INFINITY });
108
98
  function buildTools(input, resolved, wrote) {
109
99
  const { userId, wizardId } = input;
110
100
  const signal = input.signal;
@@ -314,19 +304,7 @@ function buildTools(input, resolved, wrote) {
314
304
  screenshot: Buffer.from(r.png).toString("base64"),
315
305
  };
316
306
  }),
317
- toModelOutput: (out) => {
318
- if (!out || out.error || !out.screenshot) {
319
- return { type: "json", value: out };
320
- }
321
- const { screenshot, ...rest } = out;
322
- return {
323
- type: "content",
324
- value: [
325
- { type: "text", text: JSON.stringify(rest) },
326
- { type: "image-data", data: screenshot, mediaType: "image/png" },
327
- ],
328
- };
329
- },
307
+ toModelOutput: imageOutput("screenshot"),
330
308
  }),
331
309
  };
332
310
  if (resolved.vendor === "anthropic") {
@@ -357,7 +335,7 @@ export async function runArchitect(input) {
357
335
  content: systemPrompt(input.mcpServers, input.connectors, await pluginToolsOf(currentTenant())),
358
336
  providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
359
337
  },
360
- model: resolved.model,
338
+ model: agentModel(resolved),
361
339
  tools,
362
340
  });
363
341
  const context = [
@@ -456,7 +434,7 @@ export async function runArchitect(input) {
456
434
  throw payload.error ?? new Error("architect stream error");
457
435
  }
458
436
  }
459
- const usage = (await stream.totalUsage) ?? (await stream.usage);
437
+ const usage = await totalUsage(stream);
460
438
  return {
461
439
  reply: reply.trim(),
462
440
  changed,
@@ -7,8 +7,7 @@ import { z } from "zod";
7
7
  import { knowsTools } from "../assistant/knows.js";
8
8
  import { PRIMER } from "../assistant/primer.js";
9
9
  import { currentFeatures } from "../features.js";
10
- import { attachTools, contextWindowOf, costOf, gatewayTools, textModel, usageOf, } from "../models.js";
11
- import { ServiceError } from "../services/errors.js";
10
+ import { agentModel, attachTools, contextWindowOf, costOf, gatewayTools, textModel, totalUsage, usageOf, } from "../models.js";
12
11
  import { knowledgeCounts } from "../services/knowledge.js";
13
12
  import { addProjectFileFromUrl, projectFileContent, projectFiles, updateProjectFile, } from "../services/project-files.js";
14
13
  import { documentText } from "../services/project-index.js";
@@ -17,6 +16,7 @@ import { categoryRows } from "../services/space-categories.js";
17
16
  import { currentTenant } from "../tenants/tenant.js";
18
17
  import { serverText } from "../texts.js";
19
18
  import { assistantPageOf, assistantToolsOf } from "../tools/plugin.js";
19
+ import { attempt as attemptTool, imageOutput } from "../tools/shared.js";
20
20
  import { readWebsite } from "./website.js";
21
21
  /** How a message written meanwhile reaches the model. */
22
22
  export function signalText(taken) {
@@ -97,14 +97,8 @@ function activity(tool, args, lang) {
97
97
  : tool;
98
98
  return LABELS[key] ? serverText(lang, LABELS[key]) : null;
99
99
  }
100
- async function attempt(run) {
101
- try {
102
- return await run();
103
- }
104
- catch (err) {
105
- return { error: err instanceof ServiceError ? err.message : err.message };
106
- }
107
- }
100
+ /** A tool failure the model can read and fix, instead of a crashed turn: the whole message. */
101
+ const attempt = (run) => attemptTool(run, { limit: Number.POSITIVE_INFINITY });
108
102
  async function projectState(userId, projectId) {
109
103
  const p = await ownedProject(userId, projectId);
110
104
  const files = await projectFiles(projectId);
@@ -587,18 +581,7 @@ function spaceTools(input, project, wrote, on) {
587
581
  }
588
582
  return { name: row.name, image: data.toString("base64"), mime: row.mime };
589
583
  }),
590
- toModelOutput: (out) => {
591
- if (!out?.image) {
592
- return { type: "json", value: out };
593
- }
594
- return {
595
- type: "content",
596
- value: [
597
- { type: "text", text: out.name },
598
- { type: "image-data", data: out.image, mediaType: out.mime },
599
- ],
600
- };
601
- },
584
+ toModelOutput: imageOutput("image", (rest) => String(rest.name)),
602
585
  }),
603
586
  };
604
587
  if (!on.brandSections) {
@@ -765,7 +748,7 @@ export async function runStudioAssistant(input) {
765
748
  content: SYSTEM,
766
749
  providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
767
750
  },
768
- model: resolved.model,
751
+ model: agentModel(resolved),
769
752
  tools,
770
753
  });
771
754
  const page = input.page;
@@ -895,6 +878,6 @@ export async function runStudioAssistant(input) {
895
878
  throw payload.error ?? new Error("assistant stream error");
896
879
  }
897
880
  }
898
- const usage = (await stream.totalUsage) ?? (await stream.usage);
881
+ const usage = await totalUsage(stream);
899
882
  return { ...cut(), costUsd: costOf(resolved, usage) };
900
883
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { lstatSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { promisify } from "node:util";
@@ -12,7 +12,7 @@ const run = promisify(execFile);
12
12
  * LaunchAgent, on Linux from a systemd user service. Only for an install made by install.sh,
13
13
  * whose command stays where it is.
14
14
  */
15
- const LOGIN_LABEL = "com.engenty.wizards.login";
15
+ export const LOGIN_LABEL = "com.engenty.wizards.login";
16
16
  /** The LaunchAgent. */
17
17
  export const LOGIN_ITEM = join(homedir(), "Library", "LaunchAgents", `${LOGIN_LABEL}.plist`);
18
18
  const xml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
@@ -45,7 +45,7 @@ ${program.map((arg) => ` <string>${xml(arg)}</string>\n`).join("")} </array>
45
45
  </plist>
46
46
  `;
47
47
  }
48
- const UNIT = "engenty-wizards.service";
48
+ export const UNIT = "engenty-wizards.service";
49
49
  export function unitFile(env = process.env) {
50
50
  return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user", UNIT);
51
51
  }
@@ -80,27 +80,71 @@ async function systemdHere() {
80
80
  return false;
81
81
  }
82
82
  }
83
+ /**
84
+ * Whether a file is there. Unlike existsSync, a folder it may not look into (a sandbox, its
85
+ * permissions) is an error, not "no file": `autostart off` must not say it's off then.
86
+ */
87
+ export function present(file) {
88
+ try {
89
+ lstatSync(file);
90
+ return true;
91
+ }
92
+ catch (error) {
93
+ if (error.code === "ENOENT") {
94
+ return false;
95
+ }
96
+ throw new Error(`Couldn’t check ${file}: ${error.message}`);
97
+ }
98
+ }
99
+ /**
100
+ * Whether it starts at login, read from the login item itself: a LaunchAgent or unit file that
101
+ * is there counts, whichever copy of the command wrote it. Only an install made by install.sh can
102
+ * write one.
103
+ */
83
104
  export async function autostartState(paths) {
84
105
  if (process.platform !== "darwin" && process.platform !== "linux") {
85
106
  return {
86
107
  unavailable: process.platform === "win32" ? "not on Windows" : `not on ${process.platform}`,
87
108
  };
88
109
  }
89
- if (!installedByScript(paths)) {
110
+ const file = process.platform === "darwin" ? LOGIN_ITEM : unitFile();
111
+ const there = present(file);
112
+ if (!there && !installedByScript(paths)) {
90
113
  return { unavailable: "only for installs made with the install script" };
91
114
  }
92
115
  if (process.platform === "darwin") {
93
- return { on: existsSync(LOGIN_ITEM), how: MAC_HOW };
116
+ return { on: there, how: MAC_HOW };
94
117
  }
95
118
  if (!(await systemdHere())) {
96
- return { unavailable: "this system has no systemd user session" };
119
+ return there
120
+ ? { on: true, how: LINUX_HOW }
121
+ : { unavailable: "this system has no systemd user session" };
97
122
  }
98
123
  try {
99
124
  await run("systemctl", ["--user", "is-enabled", "--quiet", UNIT], { timeout: 10_000 });
100
125
  return { on: true, how: LINUX_HOW };
101
126
  }
102
127
  catch {
103
- return { on: false, how: LINUX_HOW };
128
+ // A unit file that is there but not enabled still counts: `off` takes it away.
129
+ return { on: there, how: LINUX_HOW };
130
+ }
131
+ }
132
+ /** The LaunchAgent unloaded from this login session, if launchd has it; errors are ignored. */
133
+ export async function unloadLoginItem() {
134
+ const uid = process.getuid?.();
135
+ if (process.platform !== "darwin" || uid === undefined) {
136
+ return;
137
+ }
138
+ await run("launchctl", ["bootout", `gui/${uid}/${LOGIN_LABEL}`], { timeout: 15_000 }).catch(() => undefined);
139
+ }
140
+ /** The systemd user service disabled, stopped and its file gone. */
141
+ export async function removeUnit(file = unitFile()) {
142
+ if (findOnPath("systemctl", process.env.PATH ?? "")) {
143
+ await run("systemctl", ["--user", "disable", "--now", UNIT]).catch(() => undefined);
144
+ }
145
+ rmSync(file, { force: true });
146
+ if (findOnPath("systemctl", process.env.PATH ?? "")) {
147
+ await run("systemctl", ["--user", "daemon-reload"]).catch(() => undefined);
104
148
  }
105
149
  }
106
150
  /** Switches it on or off; what it is afterwards, or an error the person can read. */
@@ -110,7 +154,7 @@ export async function setAutostart(paths, on) {
110
154
  return state;
111
155
  }
112
156
  if (process.platform === "darwin") {
113
- // Takes effect at the next login; nothing is started now.
157
+ // Takes effect at the next login; nothing is started or stopped now.
114
158
  if (on) {
115
159
  mkdirSync(dirname(LOGIN_ITEM), { recursive: true });
116
160
  writeFileSync(LOGIN_ITEM, loginItemText(join(paths.home, "bin", "wizards"), join(paths.home, "logs", "autostart.log")));
@@ -118,7 +162,7 @@ export async function setAutostart(paths, on) {
118
162
  else {
119
163
  rmSync(LOGIN_ITEM, { force: true });
120
164
  }
121
- return { on: existsSync(LOGIN_ITEM), how: MAC_HOW };
165
+ return { on: present(LOGIN_ITEM), how: MAC_HOW };
122
166
  }
123
167
  const file = unitFile();
124
168
  if (on) {
@@ -128,9 +172,11 @@ export async function setAutostart(paths, on) {
128
172
  await run("systemctl", ["--user", "enable", "--now", UNIT]);
129
173
  }
130
174
  else {
131
- await run("systemctl", ["--user", "disable", "--now", UNIT]).catch(() => undefined);
132
- rmSync(file, { force: true });
133
- await run("systemctl", ["--user", "daemon-reload"]).catch(() => undefined);
175
+ await removeUnit(file);
134
176
  }
135
177
  return autostartState(paths);
136
178
  }
179
+ /** The state for a status line: a login item that can't be checked reads as unavailable. */
180
+ export function autostartForStatus(paths) {
181
+ return autostartState(paths).catch((error) => ({ unavailable: error.message }));
182
+ }
@@ -0,0 +1,221 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { readEnvFile, readInstallNote } from "./home.js";
5
+ import { currentVersion, promote, prune, runtimeDir, switchTo, versionIn } from "./versions.js";
6
+ /**
7
+ * Updates that come by themselves, for an install made by install.sh (never `npx wizards` or a
8
+ * checkout). The runtime asks the npm registry for the newest `wizards` when it starts and once
9
+ * a day; npm installs a newer one, checking its integrity, beside the one that runs
10
+ * (versions.ts). Nothing switches then: runs cost credits and are never cut off. The next
11
+ * `wizards start` (a restart, the next login) makes it `current` before any runtime runs.
12
+ *
13
+ * Off with `wizards autoupdate off` or WIZARDS_DISABLE_AUTOUPDATE=1 (terminal or the install's
14
+ * .env); off by default in CI and for an install set up without a terminal.
15
+ */
16
+ const LATEST_URL = "https://registry.npmjs.org/wizards/latest";
17
+ const HOUR = 60 * 60_000;
18
+ /** How long the status and the studio keep saying what came with an update. */
19
+ const NOTICE_MS = 14 * 24 * HOUR;
20
+ export const notesUrl = (version) => `https://github.com/engenty/wizards/releases/tag/v${version}`;
21
+ const stateFile = (paths) => join(paths.home, "update.json");
22
+ export function readUpdateState(paths) {
23
+ try {
24
+ return JSON.parse(readFileSync(stateFile(paths), "utf8"));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ function writeUpdateState(paths, state) {
31
+ mkdirSync(paths.home, { recursive: true });
32
+ const tmp = `${stateFile(paths)}.${process.pid}.tmp`;
33
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
34
+ renameSync(tmp, stateFile(paths));
35
+ }
36
+ /** Whether `a` is a newer version than `b`; a `v` and a pre-release suffix are ignored. */
37
+ export function isNewer(a, b) {
38
+ const parts = (v) => v.replace(/^v/, "").split("-")[0].split(".").map(Number);
39
+ const [x, y] = [parts(a), parts(b)];
40
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
41
+ if ((x[i] || 0) !== (y[i] || 0)) {
42
+ return (x[i] || 0) > (y[i] || 0);
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+ // --- whether it is on ----------------------------------------------------------------------
48
+ const truthy = (value) => value !== undefined && !["", "0", "false", "no", "off"].includes(value.trim().toLowerCase());
49
+ /** A build machine. The runtime gets ENGENTY_WIZARDS_CI from the command, which sees CI. */
50
+ export const isCI = (env) => truthy(env.CI) || truthy(env.ENGENTY_WIZARDS_CI);
51
+ /**
52
+ * Whether this install updates itself. The variable switches it off whatever else says;
53
+ * `wizards autoupdate on|off` decides next; without either it is on, except in CI and for an
54
+ * install set up without a terminal.
55
+ */
56
+ export function autoUpdatePolicy(paths, options) {
57
+ const env = options.env ?? process.env;
58
+ if (!options.installed) {
59
+ return { on: false, reason: "only an install made by install.sh updates itself" };
60
+ }
61
+ if (truthy(env.WIZARDS_DISABLE_AUTOUPDATE ?? readEnvFile(paths.envFile).WIZARDS_DISABLE_AUTOUPDATE)) {
62
+ return { on: false, reason: "WIZARDS_DISABLE_AUTOUPDATE is set" };
63
+ }
64
+ const { autoUpdate, unattended } = options.note ?? readInstallNote(paths);
65
+ if (autoUpdate !== undefined) {
66
+ return { on: autoUpdate, reason: autoUpdate ? "turned on" : "turned off" };
67
+ }
68
+ if (isCI(env)) {
69
+ return { on: false, reason: "off in CI" };
70
+ }
71
+ if (unattended) {
72
+ return { on: false, reason: "off: set up without a terminal" };
73
+ }
74
+ return { on: true, reason: "on" };
75
+ }
76
+ export const systemDeps = {
77
+ fetch: (...args) => fetch(...args),
78
+ npmInstall,
79
+ now: () => new Date(),
80
+ };
81
+ /** The version npm's `latest` tag names; null when it names none. */
82
+ async function latestVersion(deps, env) {
83
+ const response = await deps.fetch(env.ENGENTY_WIZARDS_REGISTRY_URL?.trim() || LATEST_URL, {
84
+ headers: { accept: "application/json" },
85
+ signal: AbortSignal.timeout(10_000),
86
+ });
87
+ if (!response.ok) {
88
+ throw new Error(`The npm registry answered ${response.status} for the newest version.`);
89
+ }
90
+ const { version } = (await response.json());
91
+ return typeof version === "string" && /^\d+\.\d+\.\d+$/.test(version) ? version : null;
92
+ }
93
+ /** npm of the install's own Node, as install.sh runs it; its output goes to logs/update.log. */
94
+ async function npmInstall(paths, prefix, spec) {
95
+ const bin = join(paths.tools, "node", "bin");
96
+ mkdirSync(paths.logs, { recursive: true });
97
+ const log = openSync(join(paths.logs, "update.log"), "a");
98
+ const args = "install --omit=dev --ignore-scripts --legacy-peer-deps --no-audit --no-fund --loglevel=error";
99
+ const code = await new Promise((done) => {
100
+ const child = spawn(join(bin, "npm"), [...args.split(" "), "--prefix", prefix, spec], {
101
+ stdio: ["ignore", log, log],
102
+ env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` },
103
+ });
104
+ child.on("error", () => done(127));
105
+ child.on("exit", (exit) => done(exit ?? 1));
106
+ });
107
+ if (code !== 0) {
108
+ throw new Error(`npm install of the new version failed (exit ${code}); see logs/update.log.`);
109
+ }
110
+ }
111
+ /**
112
+ * Asks npm for the newest version and, when it is newer than the current one, installs it into
113
+ * its version folder (npm checks the package against the registry's integrity hash). It switches nothing. `force` asks even
114
+ * within the hour since the last time.
115
+ */
116
+ export async function checkForUpdate(paths, options, deps = systemDeps) {
117
+ if (!options.policy.on) {
118
+ return { kind: "off", reason: options.policy.reason };
119
+ }
120
+ const state = readUpdateState(paths);
121
+ const now = deps.now();
122
+ const current = currentVersion(paths);
123
+ if (!current) {
124
+ return { kind: "skipped", reason: "no runtime installed" };
125
+ }
126
+ if (!options.force && now.getTime() - Date.parse(state.checkedAt ?? "0") < HOUR) {
127
+ return { kind: "skipped", reason: "asked within the hour" };
128
+ }
129
+ state.checkedAt = now.toISOString();
130
+ delete state.error;
131
+ const tmp = mkdtempSync(join(paths.home, "update-"));
132
+ try {
133
+ const version = await latestVersion(deps, options.env ?? process.env);
134
+ if (!version || !isNewer(version, current)) {
135
+ return { kind: "current", version: current };
136
+ }
137
+ if (state.pending?.version !== version || !versionIn(runtimeDir(paths, version))) {
138
+ await deps.npmInstall(paths, tmp, `wizards@${version}`);
139
+ promote(paths, tmp, version);
140
+ state.pending = { version, notes: notesUrl(version) };
141
+ prune(paths, keepOf(state));
142
+ }
143
+ return { kind: "downloaded", version };
144
+ }
145
+ catch (error) {
146
+ state.error = error.message;
147
+ return { kind: "failed", reason: state.error };
148
+ }
149
+ finally {
150
+ writeUpdateState(paths, state);
151
+ rmSync(tmp, { recursive: true, force: true });
152
+ }
153
+ }
154
+ /** The versions kept besides the current one: the one waiting and the one before. */
155
+ const keepOf = (state) => [state.pending?.version, state.updated?.from].filter((v) => Boolean(v));
156
+ // --- switching -------------------------------------------------------------------------------
157
+ /**
158
+ * Makes a downloaded version `current`. Only called where no runtime runs (`wizards start`
159
+ * before it starts one), so no run is cut off; a version that is no longer newer is dropped.
160
+ */
161
+ export function applyPendingUpdate(paths, now = new Date()) {
162
+ const state = readUpdateState(paths);
163
+ const { pending } = state;
164
+ const from = currentVersion(paths);
165
+ if (!pending || !from) {
166
+ return null;
167
+ }
168
+ delete state.pending;
169
+ try {
170
+ if (!isNewer(pending.version, from)) {
171
+ return null;
172
+ }
173
+ switchTo(paths, pending.version);
174
+ state.updated = { from, to: pending.version, at: now.toISOString(), notes: pending.notes };
175
+ prune(paths, [from]);
176
+ return { from, to: pending.version };
177
+ }
178
+ catch (error) {
179
+ state.error = error.message;
180
+ return null;
181
+ }
182
+ finally {
183
+ writeUpdateState(paths, state);
184
+ }
185
+ }
186
+ /** Notes a switch somebody made by hand (`wizards update`), so the notice says it too. */
187
+ export function noteUpdated(paths, from, to, now = new Date()) {
188
+ if (from !== to) {
189
+ const state = readUpdateState(paths);
190
+ delete state.pending;
191
+ state.updated = { from, to, at: now.toISOString(), notes: notesUrl(to) };
192
+ writeUpdateState(paths, state);
193
+ }
194
+ }
195
+ /** What the status and the studio say: a fresh update, or one waiting for the next start. */
196
+ export function updateNotice(paths, running, now = new Date()) {
197
+ const { updated, pending } = readUpdateState(paths);
198
+ return {
199
+ updated: updated?.to === running && now.getTime() - Date.parse(updated.at) < NOTICE_MS
200
+ ? updated
201
+ : null,
202
+ pending: pending && isNewer(pending.version, running) ? pending : null,
203
+ };
204
+ }
205
+ /** The runtime's part: a check soon after it starts, then once a day. */
206
+ export function scheduleUpdateChecks(paths, policy, deps = systemDeps) {
207
+ if (!policy().on) {
208
+ return;
209
+ }
210
+ const check = async (force) => {
211
+ const result = await checkForUpdate(paths, { policy: policy(), force }, deps);
212
+ if (result.kind === "downloaded") {
213
+ console.log(`[update] ${result.version} is ready; it runs from the next start.`);
214
+ }
215
+ else if (result.kind === "failed") {
216
+ console.error(`[update] ${result.reason}`);
217
+ }
218
+ };
219
+ setTimeout(() => void check(false), 30_000).unref();
220
+ setInterval(() => void check(true), 24 * HOUR).unref();
221
+ }
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { delimiter, dirname, join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { installedByScript, packageRoot } from "./home.js";
7
- import { findOnPath } from "./machine.js";
7
+ import { findOnPath, macAppPresent } from "./machine.js";
8
8
  import { bad, cmd, dim, ok, tilde } from "./ui.js";
9
9
  const run = promisify(execFile);
10
10
  /**
@@ -225,7 +225,8 @@ function jsonApp(def) {
225
225
  name: def.name,
226
226
  widgets: def.widgets,
227
227
  where: def.file,
228
- installed: () => existsSync(def.marker),
228
+ installed: () => existsSync(def.marker) &&
229
+ (process.platform !== "darwin" || !def.macApp || macAppPresent(def.macApp)),
229
230
  connected: async () => jsonHasServer(read(def.file) ?? "", def.at),
230
231
  async connect(server) {
231
232
  const busy = running();
@@ -423,6 +424,7 @@ export function apps(paths) {
423
424
  widgets: true,
424
425
  file: join(appSupport("Claude"), "claude_desktop_config.json"),
425
426
  marker: appSupport("Claude"),
427
+ macApp: "Claude",
426
428
  at: ["mcpServers"],
427
429
  entry: stdioEntry,
428
430
  ownsFileWhileRunning: () => processRunning(CLAUDE_DESKTOP_PROCESS),
@@ -444,6 +446,7 @@ export function apps(paths) {
444
446
  widgets: true,
445
447
  file: join(vscodeUser, "mcp.json"),
446
448
  marker: vscodeUser,
449
+ macApp: "Visual Studio Code",
447
450
  at: ["servers"],
448
451
  entry: (server) => ({ type: "stdio", ...stdioEntry(server) }),
449
452
  }),
@@ -453,6 +456,7 @@ export function apps(paths) {
453
456
  widgets: false,
454
457
  file: join(home, ".codeium", "windsurf", "mcp_config.json"),
455
458
  marker: join(home, ".codeium", "windsurf"),
459
+ macApp: "Windsurf",
456
460
  at: ["mcpServers"],
457
461
  entry: stdioEntry,
458
462
  }),
@@ -1,4 +1,5 @@
1
1
  import { basename, delimiter, dirname, join } from "node:path";
2
+ import { isCI } from "./autoupdate.js";
2
3
  /**
3
4
  * Variables of the terminal the runtime inherits. Everything else it gets comes from the
4
5
  * install's `.env` or is set by the command line. A key or a database address that the shell
@@ -40,6 +41,8 @@ const INHERITED = new Set([
40
41
  "ACCOUNT_URL",
41
42
  "ACCOUNT_GATEWAY_URL",
42
43
  "CLOUD_URL",
44
+ // Off for this start (autoupdate.ts).
45
+ "WIZARDS_DISABLE_AUTOUPDATE",
43
46
  ]);
44
47
  export function inherited(env) {
45
48
  const out = {};
@@ -82,6 +85,8 @@ export function runtimeEnv(paths, start, env = process.env, fileEnv = {}, platfo
82
85
  LOCAL_ACCESS_KEY: start.accessKey,
83
86
  // The install the runtime belongs to: the studio connects AI apps to its command.
84
87
  ...(basename(paths.home) === "wizards" ? { ENGENTY_HOME: dirname(paths.home) } : {}),
88
+ // The runtime sees no CI variables; it is told so, and checks for no updates (autoupdate.ts).
89
+ ...(isCI(env) ? { ENGENTY_WIZARDS_CI: "1" } : {}),
85
90
  // Keys entered in the settings live in the Keychain, as with the desktop app.
86
91
  ...(platform === "darwin" && !secretsGiven ? { SECRETS: "keychain" } : {}),
87
92
  };
@@ -42,13 +42,17 @@ export const CLIENTS = [
42
42
  app: "Cursor",
43
43
  },
44
44
  ];
45
+ const APP_ROOTS = ["/Applications", join(homedir(), "Applications")];
46
+ /** Whether `<name>.app` is in one of the Mac's Applications folders. */
47
+ export function macAppPresent(name, roots = APP_ROOTS) {
48
+ return roots.some((root) => existsSync(join(root, `${name}.app`)));
49
+ }
45
50
  /** The client's desktop app, when it is on this Mac: its name, e.g. "Claude". */
46
- export function vendorApp(client, roots = ["/Applications", join(homedir(), "Applications")]) {
51
+ export function vendorApp(client, roots = APP_ROOTS) {
47
52
  if (process.platform !== "darwin" || !client.app) {
48
53
  return null;
49
54
  }
50
- const app = client.app;
51
- return roots.some((root) => existsSync(join(root, `${app}.app`))) ? app : null;
55
+ return macAppPresent(client.app, roots) ? client.app : null;
52
56
  }
53
57
  /**
54
58
  * The client the setup suggests among those not installed: the one whose app is on this Mac —
@@ -12,7 +12,7 @@ import { setup } from "./setup.js";
12
12
  import { open, start, studio } from "./start.js";
13
13
  import { autostart, restart, status, stop } from "./status.js";
14
14
  import { cmd, cyan, tilde } from "./ui.js";
15
- import { update } from "./update.js";
15
+ import { autoUpdateCommand, update } from "./update.js";
16
16
  import { validateCommand } from "./validate.js";
17
17
  /** One line of the help: the name padded, then coloured, so the columns stay straight. */
18
18
  const line = (name, what) => ` ${cyan(name.padEnd(command().length + 12))}${what}`;
@@ -38,7 +38,9 @@ ${line(`${command()} report`, "send the status, model settings and last log line
38
38
  ${line(`${command()} stop`, "stop it (your data is kept)")}
39
39
  ${line(`${command()} restart`, "restart it, e.g. to switch to a newly installed version")}
40
40
  ${line(`${command()} update`, "install the latest version")}
41
- ${line(`${command()} autostart`, "start it when you log in: add on or off")}
41
+ ${line(`${command()} autoupdate`, "updates by themselves: add on or off (WIZARDS_DISABLE_AUTOUPDATE=1 is off too)")}
42
+ ${more("(downloaded in the background, used from the next start; never during a run)")}
43
+ ${line(`${command()} autostart`, "start it when you log in to this computer: add on or off")}
42
44
  ${more("(macOS: a LaunchAgent; Linux: a systemd user service)")}
43
45
  ${line(`${command()} connect`, "add your wizards to your AI apps: every app found, or the ones you name:")}
44
46
  ${more(apps(layout())
@@ -46,6 +48,8 @@ ${more(apps(layout())
46
48
  .join(", "))}
47
49
  ${line(`${command()} disconnect`, "remove your wizards from those apps (same names)")}
48
50
  ${line(`${command()} mcp`, "run the MCP server over stdio (AI apps start this)")}
51
+ ${line(`${command()} uninstall`, "take it off this computer: shows the plan, asks about your data (kept by default)")}
52
+ ${more("--dry-run only shows it; --yes asks nothing and keeps your data; --yes --delete-data")}
49
53
  ${line(`${command()} validate`, "check wizards without the studio: .wizard files, unpacked packages, wizard JSON")}
50
54
  ${more("--json prints the issues as JSON. A space’s connectors and plugins are checked on import.")}
51
55
 
@@ -112,7 +116,8 @@ function parse(argv) {
112
116
  else if (arg.startsWith("-")) {
113
117
  return `Unknown option ${arg}.`;
114
118
  }
115
- else if (["autostart", "logs", "account"].includes(args.command) && !args.value) {
119
+ else if (["autostart", "autoupdate", "logs", "account"].includes(args.command) &&
120
+ !args.value) {
116
121
  args.value = arg;
117
122
  }
118
123
  else if (["connect", "disconnect", "features"].includes(args.command)) {
@@ -190,6 +195,8 @@ async function main(argv) {
190
195
  return update(paths);
191
196
  case "autostart":
192
197
  return autostart(paths, args.value);
198
+ case "autoupdate":
199
+ return autoUpdateCommand(paths, args.value);
193
200
  case "account":
194
201
  return accountCommand(paths, args.value, { yes: args.yes });
195
202
  case "connect":