@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36

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 (45) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/mcp/orchestrator-tools.js +595 -0
  10. package/dist/core/onboarding/ensure-initialized.js +133 -0
  11. package/dist/core/repl/session.js +370 -9
  12. package/dist/core/repl/slash-commands.js +68 -5
  13. package/dist/core/smoke/headless-driver.js +174 -0
  14. package/dist/core/smoke/orchestrator.js +194 -0
  15. package/dist/core/smoke/runner.js +238 -0
  16. package/dist/core/smoke/scenario-parser.js +316 -0
  17. package/dist/runtime/cli.js +453 -11
  18. package/dist/runtime/commands/cancel.js +231 -0
  19. package/dist/runtime/commands/codegraph-status.js +227 -0
  20. package/dist/runtime/commands/mcp.js +66 -11
  21. package/dist/runtime/commands/permissions.js +23 -0
  22. package/dist/runtime/commands/redo-blob-store.js +92 -0
  23. package/dist/runtime/commands/redo.js +361 -0
  24. package/dist/runtime/commands/status.js +11 -3
  25. package/dist/runtime/commands/undo.js +32 -0
  26. package/dist/runtime/headless-repl.js +195 -0
  27. package/dist/runtime/version.js +1 -1
  28. package/dist/tui/permissions-picker.js +78 -0
  29. package/dist/tui/render.js +35 -0
  30. package/dist/tui/status-bar.js +1 -1
  31. package/dist/tui/tool-stream-pane.js +45 -3
  32. package/package.json +7 -4
  33. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  34. package/test/scenarios/compact-force.scenario.txt +11 -0
  35. package/test/scenarios/identity.scenario.txt +11 -0
  36. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  37. package/test/scenarios/walkback.scenario.txt +12 -0
  38. package/dist/core/engine/compaction-hook.js +0 -154
  39. package/dist/core/init/scaffold.js +0 -195
  40. package/dist/core/memory/dual-write.spec.js +0 -297
  41. package/dist/core/memory-sync/queue.spec.js +0 -105
  42. package/dist/core/repl/codebase-survey.js +0 -308
  43. package/dist/core/repl/init-interview.js +0 -457
  44. package/dist/core/repl/onboarding-state.js +0 -297
  45. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -1,297 +0,0 @@
1
- /**
2
- * Project onboarding state for the Pugi REPL.
3
- *
4
- * Inspired by Claude Code's projectOnboardingState pattern (the leaked
5
- * upstream file ships a 4-flag memoized check called on every prompt
6
- * submit). Independent implementation: the storage location, the steps,
7
- * the reset path, and the cap counter are all Pugi-shaped.
8
- *
9
- * # Why this exists
10
- *
11
- * The REPL's status bar can flash a `setup needed: run /init` hint when
12
- * a workspace is still ungoverned (no PUGI.md, no skills installed). The
13
- * hint must NEVER show on a workspace where the operator already ran the
14
- * interview - otherwise the prompt becomes nag-noise. Three layered
15
- * short-circuits guarantee that:
16
- *
17
- * 1. `hasCompletedOnboarding === true` in `~/.pugi/state.json` -> stop.
18
- * The interview's final phase writes this flag once; subsequent
19
- * REPL boots short-circuit at the global ledger and never even
20
- * stat the workspace.
21
- * 2. `onboardingSeenCount >= 4` -> stop. If the operator dismissed
22
- * the hint four times without finishing the interview, assume they
23
- * do not want it. The bar stays clean from boot 5 onward.
24
- * 3. `PUGI_IS_DEMO=1` -> stop. The demo / snapshot recorder sets this
25
- * so the screen capture stays free of onboarding chrome.
26
- *
27
- * If none short-circuit, the function checks the per-workspace steps
28
- * (`PUGI.md` present, at least one skill installed, auth ok, workspace
29
- * dir bound) and returns `true` only when at least one step is still
30
- * incomplete.
31
- *
32
- * # Storage shape
33
- *
34
- * The global state file is JSON-on-disk at
35
- * `~/.pugi/state.json`. It carries two scalars:
36
- *
37
- * {
38
- * "hasCompletedOnboarding": boolean,
39
- * "onboardingSeenCount": integer >= 0
40
- * }
41
- *
42
- * The file is read at most once per process (memoized) so the hot path
43
- * is in-memory. `incrementSeenCount()` re-reads the file before
44
- * mutating to avoid clobbering a sibling Pugi process's increment
45
- * (concurrent REPLs in two terminals). Atomic-ish: we write to
46
- * `state.json.tmp` then rename. A race that overwrites another
47
- * increment is benign - the worst case is a single missed +1 across
48
- * two siblings.
49
- *
50
- * # Reset path
51
- *
52
- * `rm ~/.pugi/state.json` resets every flag. Tests rely on this:
53
- * `resetOnboardingStateForTests(stateDir)` is the public helper that
54
- * removes the file and clears the memoization cache.
55
- */
56
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
57
- import { homedir } from 'node:os';
58
- import { join } from 'node:path';
59
- const DEFAULT_STATE = Object.freeze({
60
- hasCompletedOnboarding: false,
61
- onboardingSeenCount: 0,
62
- });
63
- /** Hard cap; once the operator has seen the hint this many times we stop. */
64
- export const ONBOARDING_SEEN_CAP = 4;
65
- /**
66
- * Singleton memoization cache - cleared by `resetMemoizationForTests`.
67
- * Keyed by stateDir so concurrent specs in the same process do not
68
- * interfere with each other.
69
- */
70
- const SHOULD_SHOW_CACHE = new Map();
71
- const PERSISTED_STATE_CACHE = new Map();
72
- /**
73
- * Default fs implementation - the real `node:fs` module wrapped into
74
- * the minimal surface this file uses.
75
- */
76
- const REAL_FS = {
77
- existsSync,
78
- readFileSync: (path, encoding) => readFileSync(path, encoding),
79
- writeFileSync: (path, data, options) => writeFileSync(path, data, options),
80
- mkdirSync: (path, opts) => {
81
- mkdirSync(path, opts);
82
- },
83
- renameSync,
84
- rmSync: (path, opts) => rmSync(path, opts),
85
- readdirSync: (path) => readdirSync(path),
86
- };
87
- /**
88
- * Compute the four Pugi-specific onboarding steps for the given
89
- * workspace. Pure: the only side effect is the filesystem probe (which
90
- * tests stub via `probes.fs`).
91
- */
92
- export function getSteps(probes) {
93
- const fs = probes.fs ?? REAL_FS;
94
- const pugiDirPath = join(probes.cwd, '.pugi');
95
- const hasWorkspaceDir = fs.existsSync(pugiDirPath);
96
- const hasPugiMd = fs.existsSync(join(probes.cwd, 'PUGI.md')) ||
97
- fs.existsSync(join(pugiDirPath, 'PUGI.md'));
98
- const skillsDir = join(pugiDirPath, 'skills');
99
- const hasSkill = fs.existsSync(skillsDir) && !isEmptyDir(skillsDir, fs);
100
- return [
101
- {
102
- key: 'workspace',
103
- text: 'Bind a workspace by running pugi init',
104
- isComplete: hasWorkspaceDir,
105
- isEnabled: true,
106
- },
107
- {
108
- key: 'pugimd',
109
- text: 'Run /init to write PUGI.md with project context',
110
- isComplete: hasPugiMd,
111
- isEnabled: hasWorkspaceDir,
112
- },
113
- {
114
- key: 'skills',
115
- text: 'Install at least one skill under .pugi/skills/',
116
- isComplete: hasSkill,
117
- isEnabled: hasWorkspaceDir,
118
- },
119
- {
120
- key: 'auth',
121
- text: 'Run pugi login to authenticate',
122
- isComplete: probes.authOk,
123
- isEnabled: true,
124
- },
125
- ];
126
- }
127
- /**
128
- * Roll-up over the steps. True when every ENABLED step is complete -
129
- * disabled steps (e.g. `pugimd` before `workspace` is set up) are
130
- * excluded so the operator does not get stuck on a step they cannot
131
- * action yet.
132
- */
133
- export function isOnboardingComplete(probes) {
134
- const steps = getSteps(probes);
135
- return steps
136
- .filter((s) => s.isEnabled)
137
- .every((s) => s.isComplete);
138
- }
139
- /**
140
- * The exported gate the REPL status bar consults. Memoized per
141
- * stateDir; the hot path is one map lookup once the first call has run.
142
- */
143
- export function shouldShowOnboarding(probes) {
144
- const stateDir = probes.stateDir ?? defaultStateDir();
145
- const cached = SHOULD_SHOW_CACHE.get(stateDir);
146
- if (cached !== undefined)
147
- return cached;
148
- const persisted = loadPersistedState(stateDir, probes.fs ?? REAL_FS);
149
- const isDemo = probes.isDemoOverride ?? (process.env.PUGI_IS_DEMO === '1');
150
- if (persisted.hasCompletedOnboarding) {
151
- SHOULD_SHOW_CACHE.set(stateDir, false);
152
- return false;
153
- }
154
- if (persisted.onboardingSeenCount >= ONBOARDING_SEEN_CAP) {
155
- SHOULD_SHOW_CACHE.set(stateDir, false);
156
- return false;
157
- }
158
- if (isDemo) {
159
- SHOULD_SHOW_CACHE.set(stateDir, false);
160
- return false;
161
- }
162
- const verdict = !isOnboardingComplete(probes);
163
- SHOULD_SHOW_CACHE.set(stateDir, verdict);
164
- return verdict;
165
- }
166
- /**
167
- * Idempotent setter: if the workspace is now fully onboarded, flip the
168
- * persisted flag. Cheap to call on every prompt submit because the
169
- * fast path short-circuits on the in-memory cache.
170
- *
171
- * The "maybe" prefix mirrors the upstream pattern: a no-op is the
172
- * common case; the write only fires once when the operator's most
173
- * recent action just completed the ladder.
174
- */
175
- export function maybeMarkOnboardingComplete(probes) {
176
- const stateDir = probes.stateDir ?? defaultStateDir();
177
- const fs = probes.fs ?? REAL_FS;
178
- const persisted = loadPersistedState(stateDir, fs);
179
- if (persisted.hasCompletedOnboarding)
180
- return;
181
- if (!isOnboardingComplete(probes))
182
- return;
183
- persistState(stateDir, fs, { ...persisted, hasCompletedOnboarding: true });
184
- // Invalidate the cache so the next `shouldShowOnboarding` call sees
185
- // the new value without restarting the process.
186
- SHOULD_SHOW_CACHE.delete(stateDir);
187
- }
188
- /**
189
- * Increment the seen counter. The REPL status bar calls this once per
190
- * boot where it actually rendered the hint. After
191
- * `ONBOARDING_SEEN_CAP` increments the cap guard in
192
- * `shouldShowOnboarding` flips the gate off permanently for this user.
193
- */
194
- export function incrementOnboardingSeenCount(probes) {
195
- const stateDir = probes.stateDir ?? defaultStateDir();
196
- const fs = probes.fs ?? REAL_FS;
197
- const persisted = loadPersistedState(stateDir, fs);
198
- const next = {
199
- ...persisted,
200
- onboardingSeenCount: persisted.onboardingSeenCount + 1,
201
- };
202
- persistState(stateDir, fs, next);
203
- SHOULD_SHOW_CACHE.delete(stateDir);
204
- }
205
- /**
206
- * Reset every persisted flag. Tests use this via the public helper; an
207
- * operator can achieve the same effect by `rm ~/.pugi/state.json`.
208
- */
209
- export function resetOnboardingStateForTests(probes) {
210
- const stateDir = probes.stateDir ?? defaultStateDir();
211
- const fs = probes.fs ?? REAL_FS;
212
- const file = stateFilePath(stateDir);
213
- if (fs.existsSync(file)) {
214
- fs.rmSync(file, { force: true });
215
- }
216
- SHOULD_SHOW_CACHE.delete(stateDir);
217
- PERSISTED_STATE_CACHE.delete(stateDir);
218
- }
219
- /**
220
- * Clear the in-memory memoization without touching the disk. The
221
- * spec uses this between assertions when it wants to force a re-read.
222
- */
223
- export function resetMemoizationForTests(stateDir) {
224
- if (stateDir) {
225
- SHOULD_SHOW_CACHE.delete(stateDir);
226
- PERSISTED_STATE_CACHE.delete(stateDir);
227
- return;
228
- }
229
- SHOULD_SHOW_CACHE.clear();
230
- PERSISTED_STATE_CACHE.clear();
231
- }
232
- /* ------------------------------------------------------------------ */
233
- /* Internal helpers */
234
- /* ------------------------------------------------------------------ */
235
- function defaultStateDir() {
236
- return join(homedir(), '.pugi');
237
- }
238
- function stateFilePath(stateDir) {
239
- return join(stateDir, 'state.json');
240
- }
241
- function loadPersistedState(stateDir, fs) {
242
- const cached = PERSISTED_STATE_CACHE.get(stateDir);
243
- if (cached !== undefined)
244
- return cached;
245
- const file = stateFilePath(stateDir);
246
- if (!fs.existsSync(file)) {
247
- PERSISTED_STATE_CACHE.set(stateDir, DEFAULT_STATE);
248
- return DEFAULT_STATE;
249
- }
250
- try {
251
- const raw = fs.readFileSync(file, 'utf8');
252
- const parsed = JSON.parse(raw);
253
- const normalized = {
254
- hasCompletedOnboarding: parsed.hasCompletedOnboarding === true,
255
- onboardingSeenCount: typeof parsed.onboardingSeenCount === 'number' &&
256
- Number.isFinite(parsed.onboardingSeenCount) &&
257
- parsed.onboardingSeenCount >= 0
258
- ? Math.floor(parsed.onboardingSeenCount)
259
- : 0,
260
- };
261
- PERSISTED_STATE_CACHE.set(stateDir, normalized);
262
- return normalized;
263
- }
264
- catch {
265
- // Corrupt file - treat as if it were absent. The next persist
266
- // overwrites with a clean shape; we never crash the REPL boot on
267
- // an unreadable state file.
268
- PERSISTED_STATE_CACHE.set(stateDir, DEFAULT_STATE);
269
- return DEFAULT_STATE;
270
- }
271
- }
272
- function persistState(stateDir, fs, next) {
273
- if (!fs.existsSync(stateDir)) {
274
- fs.mkdirSync(stateDir, { recursive: true });
275
- }
276
- const file = stateFilePath(stateDir);
277
- const tmp = `${file}.tmp`;
278
- fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
279
- fs.renameSync(tmp, file);
280
- PERSISTED_STATE_CACHE.set(stateDir, next);
281
- }
282
- function isEmptyDir(path, fs) {
283
- // An empty `skills/` directory counts as "no skill installed yet" so
284
- // the onboarding step stays incomplete. The injected fs shim may
285
- // omit `readdirSync` - in that case we conservatively report
286
- // non-empty (the dir existed, so the operator likely did something
287
- // intentional) to avoid false-positive nag.
288
- if (!fs.readdirSync)
289
- return false;
290
- try {
291
- return fs.readdirSync(path).length === 0;
292
- }
293
- catch {
294
- return true;
295
- }
296
- }
297
- //# sourceMappingURL=onboarding-state.js.map
@@ -1,174 +0,0 @@
1
- import { strict as assert } from 'node:assert';
2
- import { afterEach, beforeEach, describe, it } from 'node:test';
3
- import { mkdtempSync, rmSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { resolve } from 'node:path';
6
- import { runMemoryCommand } from './memory.js';
7
- import { countPending } from '../../core/memory-sync/queue.js';
8
- /**
9
- * Integration tests for the `pugi memory` runner.
10
- *
11
- * Authenticated server paths require mocking undici's `MockAgent`; the
12
- * specs below focus on the un-authenticated + arg-parsing branches
13
- * (which fully exercise the routing + queue interactions). The shared
14
- * `runMemoryCommand` is decoupled from the network for those paths
15
- * because `resolveActiveCredential` returns null without a configured
16
- * credential file under PUGI_HOME.
17
- */
18
- let tmpHome = '';
19
- const originalPugiHome = process.env.PUGI_HOME;
20
- const originalHome = process.env.HOME;
21
- const originalApiKey = process.env.PUGI_API_KEY;
22
- const originalApiUrl = process.env.PUGI_API_URL;
23
- function makeCtx() {
24
- const capture = { payloads: [], texts: [] };
25
- const ctx = {
26
- workspaceRoot: process.cwd(),
27
- json: false,
28
- writeOutput: (payload, text) => {
29
- capture.payloads.push(payload);
30
- capture.texts.push(text);
31
- },
32
- };
33
- return { ctx, capture };
34
- }
35
- beforeEach(() => {
36
- tmpHome = mkdtempSync(resolve(tmpdir(), 'pugi-memory-cli-'));
37
- // Both PUGI_HOME (used by the queue) AND HOME (used by
38
- // resolveActiveCredential -> os.homedir()) must point at the temp
39
- // dir so the spec is hermetic from the operator's real
40
- // ~/.pugi/credentials.json.
41
- process.env.PUGI_HOME = tmpHome;
42
- process.env.HOME = tmpHome;
43
- // The test machine may export real prod credentials — strip them so
44
- // the unauthenticated-paths block actually sees no credential.
45
- delete process.env.PUGI_API_KEY;
46
- delete process.env.PUGI_API_URL;
47
- });
48
- afterEach(() => {
49
- if (originalPugiHome === undefined)
50
- delete process.env.PUGI_HOME;
51
- else
52
- process.env.PUGI_HOME = originalPugiHome;
53
- if (originalHome === undefined)
54
- delete process.env.HOME;
55
- else
56
- process.env.HOME = originalHome;
57
- if (originalApiKey === undefined)
58
- delete process.env.PUGI_API_KEY;
59
- else
60
- process.env.PUGI_API_KEY = originalApiKey;
61
- if (originalApiUrl === undefined)
62
- delete process.env.PUGI_API_URL;
63
- else
64
- process.env.PUGI_API_URL = originalApiUrl;
65
- try {
66
- rmSync(tmpHome, { recursive: true, force: true });
67
- }
68
- catch {
69
- // ignore
70
- }
71
- });
72
- describe('runMemoryCommand — unauthenticated paths', () => {
73
- it('prints usage on empty subcommand', async () => {
74
- const { ctx, capture } = makeCtx();
75
- const result = await runMemoryCommand([], ctx);
76
- assert.equal(result.sub, 'help');
77
- assert.ok(capture.texts[0]?.includes('pugi memory'));
78
- });
79
- it('rejects unknown subcommand with status unknown_sub', async () => {
80
- const { ctx } = makeCtx();
81
- const result = await runMemoryCommand(['blarg'], ctx);
82
- assert.equal(result.status, 'unknown_sub');
83
- });
84
- it('returns unauthenticated for list when no credential', async () => {
85
- const { ctx } = makeCtx();
86
- const result = await runMemoryCommand(['list'], ctx);
87
- assert.equal(result.status, 'unauthenticated');
88
- });
89
- it('returns unauthenticated for sync when no credential', async () => {
90
- const { ctx } = makeCtx();
91
- const result = await runMemoryCommand(['sync'], ctx);
92
- assert.equal(result.status, 'unauthenticated');
93
- });
94
- it('queues offline writes when no credential present', async () => {
95
- const { ctx } = makeCtx();
96
- const result = await runMemoryCommand(['write', 'preference', 'pnpm', '--persona', 'mira'], ctx);
97
- assert.equal(result.status, 'queued_offline');
98
- assert.equal(result.pending, 1);
99
- // Confirm the queue file actually grew.
100
- assert.equal(countPending(resolve(tmpHome, 'memory-queue.jsonl')), 1);
101
- });
102
- it('queues offline forget when no credential present', async () => {
103
- const { ctx } = makeCtx();
104
- const result = await runMemoryCommand(['forget', 'mem-abc'], ctx);
105
- assert.equal(result.status, 'queued_offline');
106
- assert.equal(result.pending, 1);
107
- });
108
- it('rejects write missing kind', async () => {
109
- const { ctx } = makeCtx();
110
- const result = await runMemoryCommand(['write'], ctx);
111
- assert.equal(result.status, 'invalid_args');
112
- });
113
- it('rejects write with unknown kind', async () => {
114
- const { ctx } = makeCtx();
115
- const result = await runMemoryCommand(['write', 'notakind', 'content here'], ctx);
116
- assert.equal(result.status, 'invalid_args');
117
- assert.equal(result.reason, 'unknown_kind');
118
- });
119
- it('rejects write with empty content', async () => {
120
- const { ctx } = makeCtx();
121
- const result = await runMemoryCommand(['write', 'fact', ' '], ctx);
122
- assert.equal(result.status, 'invalid_args');
123
- });
124
- it('rejects forget without id', async () => {
125
- const { ctx } = makeCtx();
126
- const result = await runMemoryCommand(['forget'], ctx);
127
- assert.equal(result.status, 'invalid_args');
128
- });
129
- it('rejects recall without query', async () => {
130
- const { ctx } = makeCtx();
131
- const result = await runMemoryCommand(['recall'], ctx);
132
- assert.equal(result.status, 'invalid_args');
133
- });
134
- it('sync with no pending ops returns sync_noop when offline-then-online flow', async () => {
135
- // Two writes queued offline (no credential present), then sync called
136
- // — sync without credential still returns unauthenticated, but the
137
- // queued ops persist. After credential lands, sync would push them.
138
- const { ctx } = makeCtx();
139
- await runMemoryCommand(['write', 'fact', 'queued one'], ctx);
140
- await runMemoryCommand(['write', 'fact', 'queued two'], ctx);
141
- assert.equal(countPending(resolve(tmpHome, 'memory-queue.jsonl')), 2);
142
- const { ctx: ctx2 } = makeCtx();
143
- const result = await runMemoryCommand(['sync'], ctx2);
144
- // Without an active credential, sync returns unauthenticated.
145
- assert.equal(result.status, 'unauthenticated');
146
- // The queued ops are still on disk for a later authenticated sync.
147
- assert.equal(countPending(resolve(tmpHome, 'memory-queue.jsonl')), 2);
148
- });
149
- it('write+sync flow keeps queue intact across calls', async () => {
150
- const { ctx } = makeCtx();
151
- await runMemoryCommand(['write', 'workflow', 'sample workflow'], ctx);
152
- const path = resolve(tmpHome, 'memory-queue.jsonl');
153
- assert.equal(countPending(path), 1);
154
- // A second call should append, not overwrite.
155
- await runMemoryCommand(['write', 'fact', 'another fact'], ctx);
156
- assert.equal(countPending(path), 2);
157
- });
158
- });
159
- describe('runMemoryCommand — flag parsing', () => {
160
- it('parses --persona and --kind flags on list', async () => {
161
- const { ctx, capture } = makeCtx();
162
- // list path requires auth — we only verify the flag-parsing path
163
- // does not throw + surfaces the unauth status.
164
- const result = await runMemoryCommand(['list', '--persona', 'marcus', '--kind', 'pattern', '--limit', '5'], ctx);
165
- assert.equal(result.status, 'unauthenticated');
166
- assert.equal(capture.payloads.length, 1);
167
- });
168
- it('parses --top-k on recall', async () => {
169
- const { ctx } = makeCtx();
170
- const result = await runMemoryCommand(['recall', 'what is pugi', '--top-k', '3'], ctx);
171
- assert.equal(result.status, 'unauthenticated');
172
- });
173
- });
174
- //# sourceMappingURL=memory.spec.js.map