spiega 0.1.1 → 0.1.2

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
@@ -176,6 +176,54 @@ never built. `spiega doctor` finds it:
176
176
  It exits non-zero when something is wrong, so it can go in a shell prompt or a
177
177
  pre-flight check. It makes no network calls and changes nothing.
178
178
 
179
+ ### Developing on spiega, or just using it
180
+
181
+ Installing the package changes the **command**. It does not change the **agent
182
+ setup files**, and those are what an agent actually reads. The two move
183
+ independently, which is the whole reason this section exists:
184
+
185
+ | | set by | points at |
186
+ |---|---|---|
187
+ | the `spiega` command | `npm install -g` / `npm link` | whichever you ran last |
188
+ | skills, MCP entries, `AGENTS.md` blocks | **`spiega install`** | wherever spiega was *when you ran it* |
189
+
190
+ So `npm install -g spiega` on a machine that once had the repo linked leaves
191
+ every agent still loading the working tree — uncommitted edits and all — while
192
+ `spiega` on your own PATH runs the published copy. Nothing warns you: both work,
193
+ and they are different code.
194
+
195
+ `spiega doctor` is what tells you, and it names the path it found:
196
+
197
+ ```
198
+ ✗ claude skill linked to ~/Documents/…/spiega/skills/spiega, not this install
199
+ ✗ claude MCP points at ~/Documents/…/spiega/src/mcp/server.js
200
+ ```
201
+
202
+ **To use the published version**, as anyone else would:
203
+
204
+ ```bash
205
+ npm install -g spiega
206
+ spiega install # rewires skills and MCP to the installed copy, backing up what it replaces
207
+ ```
208
+
209
+ **To go back to developing**, with agents reading your working tree:
210
+
211
+ ```bash
212
+ cd spiega && npm link
213
+ spiega install
214
+ ```
215
+
216
+ Two things that bite:
217
+
218
+ - **Restart your agent sessions.** A running MCP server holds the code it was
219
+ launched with, so until it restarts it keeps serving the old location
220
+ whatever the config now says. `doctor` counts these.
221
+ - **Check `which -a spiega`, not `which spiega`.** An older install may have
222
+ left a launcher in `~/.local/bin` or `/opt/homebrew/bin` with an absolute
223
+ path baked in. Those shadow the npm copy or are shadowed by it depending on
224
+ PATH order — and an agent's non-interactive shell often has a different PATH
225
+ than yours.
226
+
179
227
  ### If you use herdr
180
228
 
181
229
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spiega",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A hand-drawn board an AI agent draws on while it explains software architecture.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/doctor.js CHANGED
@@ -47,11 +47,43 @@ const bad = (what, detail, fix) => ({ level: 'bad', what, detail, fix });
47
47
 
48
48
  const tilde = p => String(p).replace(HOME, '~');
49
49
 
50
+ /* Most people meet spiega through `npm install -g`, where there is no
51
+ checkout and the word is simply wrong. Developers run it from a clone,
52
+ where "install" would be equally wrong. One word, decided once. */
53
+ const THIS = () => (isCheckout() ? 'this checkout' : 'this install');
54
+
55
+ /* Is ROOT itself a repository? `git -C` walks up, so containment is not
56
+ enough — see checkout() for what that cost. */
57
+ let _isCheckout = null;
58
+ function isCheckout() {
59
+ if (_isCheckout === null) {
60
+ const top = sh('git', ['-C', ROOT, 'rev-parse', '--show-toplevel']);
61
+ try {
62
+ _isCheckout = Boolean(top) && fs.existsSync(top) &&
63
+ fs.realpathSync(top) === fs.realpathSync(ROOT);
64
+ } catch { _isCheckout = false; }
65
+ }
66
+ return _isCheckout;
67
+ }
68
+
50
69
  /* ---------- the checkout ---------- */
51
70
 
52
71
  function checkout(rows) {
53
- const head = sh('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD']);
54
- if (!head) { rows.push(warn('checkout', 'not a git checkout, so there is nothing to compare against')); return 0; }
72
+ /* `git -C <dir>` walks UP until it finds a repository, so asking it about a
73
+ directory that is not one answers about whatever encloses it. Installed
74
+ from npm under nvm — which is itself a git clone — that is nvm: doctor
75
+ reported nvm's HEAD as "this checkout", nvm's dirty file as uncommitted
76
+ work, and then measured every skill and MCP entry against it. A page of
77
+ red crosses, all meaningless, on the most ordinary install there is.
78
+
79
+ So the repository has to BE this directory, not merely contain it. */
80
+ const head = isCheckout() ? sh('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD']) : null;
81
+ if (!head) {
82
+ rows.push(warn('checkout', sh('git', ['-C', ROOT, 'rev-parse', '--show-toplevel'])
83
+ ? 'installed from npm, not a checkout — nothing to compare against'
84
+ : 'not a git checkout, so there is nothing to compare against'));
85
+ return 0;
86
+ }
55
87
 
56
88
  const when = Number(sh('git', ['-C', ROOT, 'log', '-1', '--format=%ct']) || 0) * 1000;
57
89
  const subject = sh('git', ['-C', ROOT, 'log', '-1', '--format=%s']) || '';
@@ -159,8 +191,8 @@ function invocation(rows) {
159
191
 
160
192
  const mine = [path.resolve(ROOT, 'bin', 'spiega'), path.resolve(ROOT, 'src', 'cli.js')];
161
193
  rows.push(mine.includes(real)
162
- ? ok('spiega is this checkout', `${tilde(which)} → ${tilde(real)}`)
163
- : bad('spiega is this checkout', `${tilde(which)} → ${tilde(real)}, which is NOT this checkout`,
194
+ ? ok(`spiega is ${THIS()}`, `${tilde(which)} → ${tilde(real)}`)
195
+ : bad(`spiega is ${THIS()}`, `${tilde(which)} → ${tilde(real)}, which is NOT ${THIS()}`,
164
196
  'spiega install'));
165
197
 
166
198
  const floor = nodeFloor();
@@ -212,8 +244,8 @@ function skills(rows) {
212
244
  if (st.isSymbolicLink()) {
213
245
  const to = fs.readlinkSync(dest);
214
246
  rows.push(to === SKILL_SRC
215
- ? ok(`${name} skill`, 'linked — tracks the checkout')
216
- : bad(`${name} skill`, `linked to ${tilde(to)}, not this checkout`, `spiega install ${name}`));
247
+ ? ok(`${name} skill`, isCheckout() ? 'linked — tracks the checkout' : 'linked')
248
+ : bad(`${name} skill`, `linked to ${tilde(to)}, not ${THIS()}`, `spiega install ${name}`));
217
249
  continue;
218
250
  }
219
251
  // a copy: current only until the source changes, so compare it
@@ -283,7 +315,7 @@ function registrations(rows) {
283
315
  const entry = pick(readJSON(file));
284
316
  if (!entry) { rows.push(warn(`${name} MCP`, 'not registered', `spiega install ${name}`)); continue; }
285
317
  rows.push(entry === MCP_ENTRY
286
- ? ok(`${name} MCP`, 'points at this checkout')
318
+ ? ok(`${name} MCP`, `points at ${THIS()}`)
287
319
  : bad(`${name} MCP`, `points at ${tilde(entry)}`, `spiega install ${name}`));
288
320
  }
289
321
  // Codex keeps its servers in TOML
@@ -292,8 +324,8 @@ function registrations(rows) {
292
324
  const raw = fs.readFileSync(toml, 'utf8');
293
325
  const has = /^\s*\[mcp_servers\.spiega\]/m.test(raw);
294
326
  rows.push(has && raw.includes(MCP_ENTRY)
295
- ? ok('codex MCP', 'points at this checkout')
296
- : warn('codex MCP', has ? 'registered, but not at this checkout' : 'not registered', 'spiega install codex'));
327
+ ? ok('codex MCP', `points at ${THIS()}`)
328
+ : warn('codex MCP', has ? `registered, but not at ${THIS()}` : 'not registered', 'spiega install codex'));
297
329
  }
298
330
  }
299
331
 
package/test/smoke.mjs CHANGED
@@ -656,6 +656,60 @@ test('colour and glyphs stay out of piped output', async () => {
656
656
  assert.ok(short.endsWith('spiega'), 'the end is what names it');
657
657
  });
658
658
 
659
+ /* `git -C <dir>` walks UP until it finds a repository. npm's global prefix
660
+ under nvm sits inside nvm's own clone, so doctor answered about nvm:
661
+ reporting its HEAD as "this checkout", its dirty file as uncommitted work,
662
+ and then measuring every skill and MCP entry against it. The install is the
663
+ ordinary one — most people get Node from nvm — so the output almost nobody
664
+ should see was the output almost everybody got. */
665
+ test('doctor does not mistake an enclosing repo for its own checkout', async () => {
666
+ const cp = await import('node:child_process');
667
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spiega-enclosed-'));
668
+ try {
669
+ const git = (...a) => cp.execFileSync('git', ['-C', tmp, ...a],
670
+ { stdio: 'pipe', encoding: 'utf8' });
671
+ git('init', '-q');
672
+ fs.writeFileSync(path.join(tmp, 'f'), 'x');
673
+ git('add', '-A');
674
+ git('-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false',
675
+ 'commit', '-qm', 'the enclosing repo, not spiega');
676
+ fs.writeFileSync(path.join(tmp, 'dirty'), 'uncommitted');
677
+
678
+ // the package, copied in with no .git of its own — an npm install
679
+ const pkg = path.join(tmp, 'lib', 'node_modules', 'spiega');
680
+ fs.mkdirSync(pkg, { recursive: true });
681
+ for (const d of ['src', 'bin', 'skills', 'integrations']) {
682
+ if (fs.existsSync(path.join(ROOT, d))) fs.cpSync(path.join(ROOT, d), path.join(pkg, d), { recursive: true });
683
+ }
684
+ fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(pkg, 'package.json'));
685
+
686
+ const home = path.join(tmp, 'home');
687
+ fs.mkdirSync(home, { recursive: true });
688
+ // doctor exits non-zero whenever it finds anything to report, which here
689
+ // it will — the point is what it says, not what it returns
690
+ let out;
691
+ try {
692
+ out = cp.execFileSync(process.execPath, [path.join(pkg, 'src', 'cli.js'), 'doctor'], {
693
+ encoding: 'utf8',
694
+ env: { HOME: home, PATH: '/usr/bin:/bin', SPIEGA_ASCII: '1', NO_COLOR: '1' },
695
+ });
696
+ } catch (e) {
697
+ out = String(e.stdout || '');
698
+ assert.ok(out, `doctor produced no output: ${e.stderr || e.message}`);
699
+ }
700
+
701
+ const line = out.split('\n').find(l => /\bcheckout\b/.test(l)) || '';
702
+ assert.match(line, /not a checkout/,
703
+ `doctor claimed a checkout it does not have: ${line.trim()}`);
704
+ assert.ok(!/the enclosing repo/.test(out),
705
+ 'the enclosing repo\'s commit must not appear anywhere in the report');
706
+ assert.ok(!/uncommitted changes/.test(out),
707
+ 'nor its dirty working tree');
708
+ } finally {
709
+ fs.rmSync(tmp, { recursive: true, force: true });
710
+ }
711
+ });
712
+
659
713
  /* A node that fans out sends several edges through the same gutter. Each tag
660
714
  lifts itself clear of the same box, independently, and lands in the same
661
715
  place — and nothing downstream notices, because each pill draws over the