basis-design 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.
package/README.md CHANGED
@@ -18,12 +18,12 @@ npx basis-design
18
18
 
19
19
  Or install it once with `npm install -g basis-design` and start it with `basis`. Open http://localhost:5320. Basis starts Claude Code by default (shown as Claude in the picker above the chat); switch to Codex there. Basis needs Node.js 22.12 or later and `git` on the PATH.
20
20
 
21
- - **Claude Code:** log in once with `claude` in a terminal. Basis starts it through `@agentclientprotocol/claude-agent-acp`.
21
+ - **Claude Code:** install it and log in once with `claude` in a terminal. Basis runs your own Claude Code through `@agentclientprotocol/claude-agent-acp`, which npx fetches on first use without a copy of Claude Code of its own. If Basis does not find it, set `CLAUDE_CODE_EXECUTABLE` to its path.
22
22
  - **Codex:** log in once with `codex login`. Basis starts it through `npx @agentclientprotocol/codex-acp`, at a fixed version that npx fetches on first use.
23
23
 
24
24
  Usage counts against your own plan. A turn can take up to three agent runs (the turn and up to two fix-ups), and ×2 or ×3 alternatives multiply that.
25
25
 
26
- The first install is large, about 300 MB, because the Claude adapter brings its own copy of Claude Code. To work on Basis itself, clone the repository and run `npm install` and `npm run dev`.
26
+ To work on Basis itself, clone the repository and run `npm install` and `npm run dev`.
27
27
 
28
28
  | Variable | Default | Purpose |
29
29
  | --- | --- | --- |
@@ -42,6 +42,7 @@ A project can link the **product repo** it designs for (project menu, **Link pro
42
42
  - **New project** starts from Basis' defaults, or as a copy of the open project's tokens and components (not its history). It lives in `~/.basis/workspaces/`.
43
43
  - **Open folder** adds a workspace from anywhere, or an empty or new folder for Basis to set up, inside a product repo for instance. Basis refuses a folder that already has other files, so it never starts a history in someone's project.
44
44
  - Switching waits while the agent works. The agent of the project you leave stops, and coming back resumes its conversation, so it remembers where you were. The export remembers its target folder per project.
45
+ - When Basis' starter components improve, the files a project never changed update by themselves. For the ones the agent changed, the project menu offers a guided turn that brings the improvements in, as a review, keeping the project's own look.
45
46
  - Removing a project from the list never deletes its folder. Earlier versions kept the project list in the Basis folder; it moves to `~/.basis` on first start, and its projects stay where they are. The `./workspace` of versions before projects becomes the first project.
46
47
 
47
48
  ## The workspace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basis-design",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Prompt a complete design system into existence with your own coding agent over ACP.",
5
5
  "keywords": [
6
6
  "design-system",
@@ -39,12 +39,13 @@
39
39
  "start": "tsx server/index.ts",
40
40
  "typecheck": "tsc --noEmit",
41
41
  "test": "node --import tsx --test \"test/*.test.ts\"",
42
+ "test:e2e": "playwright test",
43
+ "template:history": "tsx scripts/template-history.ts",
42
44
  "lint": "oxlint",
43
45
  "format": "prettier --write .",
44
46
  "check": "tsc --noEmit && oxlint && prettier --check . && npm test"
45
47
  },
46
48
  "dependencies": {
47
- "@agentclientprotocol/claude-agent-acp": "0.92.0",
48
49
  "@agentclientprotocol/sdk": "^1.9.0",
49
50
  "@types/react": "^19.3.0",
50
51
  "@types/react-dom": "^19.3.0",
@@ -60,6 +61,7 @@
60
61
  "zod": "^4.6.7"
61
62
  },
62
63
  "devDependencies": {
64
+ "@playwright/test": "^1.64.1",
63
65
  "@types/node": "^26.6.5",
64
66
  "@types/ws": "^8.18.2",
65
67
  "oxlint": "1.88.0",
package/server/agent.ts CHANGED
@@ -2,7 +2,6 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { EventEmitter } from "node:events";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
- import { createRequire } from "node:module";
6
5
  import path from "node:path";
7
6
  import { Readable, Writable } from "node:stream";
8
7
  import * as acp from "@agentclientprotocol/sdk";
@@ -22,35 +21,52 @@ import { ALTERNATIVE_LABELS, describeTarget, MAX_ALTERNATIVES } from "../shared/
22
21
  import { loadChat, saveChat } from "./chatlog.ts";
23
22
  import { PRODUCT_FILE, relativeInside } from "./paths.ts";
24
23
  import { AGENT_BRIEF, FIRST_PROMPT_PREAMBLE } from "./brief.ts";
24
+ import { CLAUDE_MISSING, findClaude } from "./claude.ts";
25
25
  import type { Workspace } from "./workspace.ts";
26
26
 
27
- const require = createRequire(import.meta.url);
28
-
29
27
  interface LaunchSpec {
30
28
  command: string;
31
29
  args: string[];
32
30
  shell: boolean;
33
31
  loginHint: string;
32
+ /** Variables the agent needs on top of Basis' own environment. */
33
+ env?: Record<string, string>;
34
+ /** Why the agent cannot start, shown instead of starting it. */
35
+ missing?: string;
36
+ /** Added when the agent exits on its own, for the failures an update fixes. */
37
+ exitHint?: string;
34
38
  }
35
39
 
36
- /** The Codex adapter Basis was tested with; npx fetches it on first use. */
40
+ /**
41
+ * The adapters Basis was tested with, at fixed versions: an unpinned one would fetch whatever was published last.
42
+ * npx fetches each on first use and reuses it after (--prefer-offline, so Basis also starts offline).
43
+ */
44
+ const CLAUDE_ACP = "@agentclientprotocol/claude-agent-acp@0.92.0";
37
45
  const CODEX_ACP = "@agentclientprotocol/codex-acp@2.2.2";
38
46
 
39
47
  /** How each supported agent is started. Both reuse the user's own CLI login, so no API keys are needed. */
40
48
  function launchSpec(id: AgentId): LaunchSpec {
49
+ // The end-to-end tests run a stand-in agent (test/e2e/fake-agent.mjs), since no CLI is logged in on CI.
50
+ if (process.env.BASIS_AGENT_SCRIPT) {
51
+ const script = path.resolve(process.env.BASIS_AGENT_SCRIPT);
52
+ return { command: process.execPath, args: [script], shell: false, loginHint: "" };
53
+ }
41
54
  if (id === "claude") {
42
- const pkg = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
55
+ // Without --omit=optional the adapter's SDK brings its own copy of Claude Code; Basis runs the user's instead.
56
+ const claude = findClaude();
43
57
  return {
44
- command: process.execPath,
45
- args: [path.join(path.dirname(pkg), "dist", "index.js")],
46
- shell: false,
58
+ command: "npx",
59
+ args: ["-y", "--prefer-offline", "--omit=optional", CLAUDE_ACP],
60
+ shell: true,
61
+ env: claude ? { CLAUDE_CODE_EXECUTABLE: claude } : undefined,
62
+ missing: claude ? undefined : CLAUDE_MISSING,
47
63
  loginHint: "Run `claude` in a terminal and log in, then restart the agent.",
64
+ exitHint: "If it keeps happening, update Claude Code with `claude update`.",
48
65
  };
49
66
  }
50
67
  return {
51
68
  command: "npx",
52
- // A fixed version: an unpinned one would fetch whatever was published last on every start.
53
- args: ["-y", CODEX_ACP],
69
+ args: ["-y", "--prefer-offline", CODEX_ACP],
54
70
  shell: true,
55
71
  loginHint: "Run `codex login` in a terminal, then restart the agent.",
56
72
  };
@@ -180,13 +196,17 @@ export class AgentHost extends EventEmitter<Events> {
180
196
  await this.stop();
181
197
  this.setState({ id: agentId, status: "starting", detail: "Starting agent…" });
182
198
  const spec = launchSpec(agentId);
199
+ if (spec.missing) {
200
+ this.fail(spec.missing);
201
+ return;
202
+ }
183
203
  // Through a shell, the command goes as one line: Node no longer accepts separate arguments there.
184
204
  const [command, args] = spec.shell ? [[spec.command, ...spec.args].join(" "), []] : [spec.command, spec.args];
185
205
  const proc = spawn(command, args, {
186
206
  cwd: this.workspace.dir,
187
207
  shell: spec.shell,
188
208
  stdio: ["pipe", "pipe", "pipe"],
189
- env: agentEnv(),
209
+ env: { ...agentEnv(), ...spec.env },
190
210
  windowsHide: true,
191
211
  });
192
212
  this.proc = proc;
@@ -204,7 +224,8 @@ export class AgentHost extends EventEmitter<Events> {
204
224
  this.connection = undefined;
205
225
  this.sessionId = undefined;
206
226
  const tail = this.stderrTail.slice(-3).join(" ");
207
- this.fail(`The agent exited (code ${code ?? "?"}).${tail ? ` ${tail}` : ""}`);
227
+ const hint = spec.exitHint ? ` ${spec.exitHint}` : "";
228
+ this.fail(`The agent exited (code ${code ?? "?"}).${tail ? ` ${tail}` : ""}${hint}`);
208
229
  });
209
230
 
210
231
  const stream = acp.ndJsonStream(
package/server/brief.ts CHANGED
@@ -350,6 +350,24 @@ export function importInstruction(repo: string): string {
350
350
  ].join("\n");
351
351
  }
352
352
 
353
+ /**
354
+ * The guided turn that brings a newer starter template into a workspace whose agent changed the starter files.
355
+ * `changes` is the part of template/CHANGES.md the workspace has not caught up with.
356
+ */
357
+ export function templateInstruction(changes: string, files: string[]): string {
358
+ return [
359
+ "Basis has improved its starter template since this design system started from it. Bring the improvements below into this workspace's own components, patterns and views, in this turn.",
360
+ "",
361
+ changes,
362
+ "",
363
+ `1. The current template is in .basis/template/ (components/, patterns/, views/). These files here differ from it because they were changed: ${files.join(", ")}. Read each and its template version with your read tool; the list above says what changed, so you need no shell commands or diffs.`,
364
+ "2. Bring in each improvement where the workspace's file still has the part it concerns. Keep this design system's own look, tokens, variants and additions, and every contract. If the workspace already solves it another way, leave it.",
365
+ "3. Edit the files with your tools; never copy a template file over the workspace's own.",
366
+ "4. Write review.json showing the specimens that changed most (up to four), so the user compares before and after.",
367
+ "Finish with a short summary: which improvements you brought in and where, and what you left out and why.",
368
+ ].join("\n");
369
+ }
370
+
353
371
  /** Prepended to the first prompt when the agent cannot take an appended system prompt. */
354
372
  export const FIRST_PROMPT_PREAMBLE =
355
373
  "You are working inside Basis. Read AGENTS.md in the current folder first and follow it: you edit only design-system.json, review.json, product.md and files under components/, patterns/ and views/.";
@@ -0,0 +1,72 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /*
6
+ * Basis runs the user's own Claude Code rather than a copy of its own: the Claude adapter is fetched without the
7
+ * native binary its SDK would otherwise bring (about 260 MB), and is pointed at the user's executable through
8
+ * CLAUDE_CODE_EXECUTABLE. The user already has it, logged in, since that is how they sign in to Claude.
9
+ */
10
+
11
+ interface Where {
12
+ env: NodeJS.ProcessEnv;
13
+ platform: NodeJS.Platform;
14
+ home: string;
15
+ }
16
+
17
+ const isFile = (file: string) => {
18
+ try {
19
+ return statSync(file).isFile();
20
+ } catch {
21
+ return false;
22
+ }
23
+ };
24
+
25
+ /**
26
+ * The user's Claude Code executable, or null when it is not installed. In order: CLAUDE_CODE_EXECUTABLE when it
27
+ * names a file, `claude` on the PATH, then the native installer's folders. On Windows the npm install puts a
28
+ * `claude.cmd` shim on the PATH, which cannot be started without a shell, so the executable behind it is used.
29
+ */
30
+ export function findClaude(
31
+ { env, platform, home }: Where = { env: process.env, platform: process.platform, home: os.homedir() },
32
+ ): string | null {
33
+ const explicit = env.CLAUDE_CODE_EXECUTABLE;
34
+ if (explicit && isFile(explicit)) return explicit;
35
+
36
+ const windows = platform === "win32";
37
+ // Paths are on this machine's file system; `platform` only decides the names and the PATH separator.
38
+ const join = path.join;
39
+ const pathVar = env.PATH ?? env.Path ?? "";
40
+ const dirs = pathVar.split(windows ? ";" : ":").filter(Boolean);
41
+ for (const dir of dirs) {
42
+ if (windows) {
43
+ const exe = join(dir, "claude.exe");
44
+ if (isFile(exe)) return exe;
45
+ // The npm shims (claude, claude.cmd) start this executable.
46
+ const npm = join(dir, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
47
+ if ((isFile(join(dir, "claude.cmd")) || isFile(join(dir, "claude"))) && isFile(npm)) return npm;
48
+ } else {
49
+ const bin = join(dir, "claude");
50
+ if (isFile(bin)) return realpath(bin);
51
+ }
52
+ }
53
+
54
+ const installed = windows
55
+ ? [join(home, ".local", "bin", "claude.exe")]
56
+ : [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
57
+ for (const file of installed) if (isFile(file)) return windows ? file : realpath(file);
58
+ return null;
59
+ }
60
+
61
+ /** Symlinks (the native installer links to a versioned binary) resolved, so the agent starts the binary itself. */
62
+ function realpath(file: string): string {
63
+ try {
64
+ return realpathSync(file);
65
+ } catch {
66
+ return file;
67
+ }
68
+ }
69
+
70
+ /** What the user is told when Basis cannot find Claude Code. */
71
+ export const CLAUDE_MISSING =
72
+ "Basis runs your own Claude Code, and it is not installed. Install it (https://claude.com/claude-code), log in once with `claude` in a terminal, then pick Claude again. If it is installed, set CLAUDE_CODE_EXECUTABLE to its path.";
package/server/index.ts CHANGED
@@ -13,11 +13,12 @@ import {
13
13
  type ProjectEntry,
14
14
  type ServerMessage,
15
15
  } from "../shared/protocol.ts";
16
- import { createInstruction, importInstruction, viewInstruction } from "./brief.ts";
16
+ import { createInstruction, importInstruction, templateInstruction, viewInstruction } from "./brief.ts";
17
17
  import { buildStarterKit, slug, writeStarterKit, zipStarterKit } from "./export.ts";
18
18
  import { PreviewReports } from "./previews.ts";
19
19
  import { guard, isLocalHost, OpenProject, refuseHost, type ProjectOptions } from "./project.ts";
20
20
  import { checkRepo, Projects } from "./projects.ts";
21
+ import { templateChanges } from "./template.ts";
21
22
 
22
23
  // History, reviews and restores all run on git; without it nothing below works, and the error would not say why.
23
24
  if (spawnSync("git", ["--version"], { windowsHide: true }).error) {
@@ -94,7 +95,8 @@ const chromeVite = await createVite({
94
95
  appType: "spa",
95
96
  cacheDir: "node_modules/.vite/chrome",
96
97
  optimizeDeps: { entries: ["index.html"] },
97
- server: { middlewareMode: true, hmr: { server: chromeServer } },
98
+ // Vite forwards browser console errors to the terminal when it thinks an agent runs it; for Basis that is noise.
99
+ server: { middlewareMode: true, hmr: { server: chromeServer }, forwardConsole: false },
98
100
  });
99
101
  const chromeGuard = guard((url) => url === "/preview.html");
100
102
  chromeServer.on("request", (req, res) => {
@@ -381,9 +383,27 @@ async function handle(message: ClientMessage, socket: WebSocket): Promise<void>
381
383
  }
382
384
  case "import-design":
383
385
  return bringInDesign(socket);
386
+ case "template-update":
387
+ return bringInTemplate();
388
+ case "template-skip":
389
+ return current.workspace.settleTemplateUpdate();
384
390
  }
385
391
  }
386
392
 
393
+ /**
394
+ * Starts (or queues) the guided turn that brings a newer starter template's changes into the workspace's changed
395
+ * starter files. The workspace counts as caught up once the turn has run (or is queued); its review shows what came
396
+ * in.
397
+ */
398
+ async function bringInTemplate(): Promise<void> {
399
+ const update = current.workspace.templateUpdate;
400
+ if (!update) return;
401
+ const workspace = current.workspace;
402
+ const instruction = templateInstruction(await templateChanges(update.from), update.files);
403
+ await current.agent.prompt("Bring in the improvements to Basis' starter template", { instruction });
404
+ await workspace.settleTemplateUpdate();
405
+ }
406
+
387
407
  /** Starts (or queues, while the agent works) the guided turn that brings in the linked repo's design system. */
388
408
  async function bringInDesign(socket: WebSocket): Promise<void> {
389
409
  const repo = current.entry.repo;
package/server/project.ts CHANGED
@@ -99,6 +99,8 @@ export class OpenProject {
99
99
  },
100
100
  server: {
101
101
  middlewareMode: true,
102
+ // Agent-written code logs in the preview; its console belongs in the browser, not in the terminal.
103
+ forwardConsole: false,
102
104
  hmr: { server: previewServer },
103
105
  fs: { allow: [root, entry.dir] },
104
106
  // 'self': the preview embeds a copy of itself for the accessibility check in the other theme.