@pugi/cli 0.1.0-beta.10 → 0.1.0-beta.12
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/dist/core/engine/anvil-client.js +16 -0
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/native-pugi.js +112 -12
- package/dist/core/engine/tool-bridge.js +267 -8
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +46 -0
- package/dist/core/repl/slash-commands.js +8 -0
- package/dist/core/settings.js +28 -0
- package/dist/runtime/cli.js +91 -14
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tui/repl-render.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
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
|
|
@@ -663,6 +663,52 @@ export class ReplSession {
|
|
|
663
663
|
await this.dispatchPrivacy();
|
|
664
664
|
return verdict;
|
|
665
665
|
}
|
|
666
|
+
case 'init': {
|
|
667
|
+
// β1 Sl11 → β1a r1 (real inline scaffold, 2026-05-26): invoke
|
|
668
|
+
// `scaffoldPugiWorkspace` directly so the operator gets the
|
|
669
|
+
// same .pugi/ setup they would from `pugi init` on a fresh
|
|
670
|
+
// shell. Already-initialised workspaces (every artifact already
|
|
671
|
+
// present) get the "Already initialised" copy; partial / fresh
|
|
672
|
+
// workspaces get the full Created+Skipped breakdown. Default
|
|
673
|
+
// skills install is best-effort — any error from the bundled
|
|
674
|
+
// pack is surfaced as a system line and does not break the
|
|
675
|
+
// REPL session. The dynamic import keeps the slash dispatcher
|
|
676
|
+
// free of a runtime/cli.ts cycle on every keystroke.
|
|
677
|
+
try {
|
|
678
|
+
const { scaffoldPugiWorkspace } = await import('../../runtime/cli.js');
|
|
679
|
+
const lines = [];
|
|
680
|
+
const result = await scaffoldPugiWorkspace({
|
|
681
|
+
cwd: process.cwd(),
|
|
682
|
+
// Slash callers default to the full default-skills pack so
|
|
683
|
+
// the in-REPL experience matches `pugi init`. Operators who
|
|
684
|
+
// want a minimal scaffold still have the shell command.
|
|
685
|
+
noDefaults: false,
|
|
686
|
+
log: (line) => {
|
|
687
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
688
|
+
if (trimmed.length > 0)
|
|
689
|
+
lines.push(trimmed);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
if (result.alreadyInitialized) {
|
|
693
|
+
this.appendSystemLine(`.pugi/ already initialised at ${result.root}. ${result.skipped.length} artefact(s) verified.`);
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
this.appendSystemLine(`Pugi initialised at ${result.root}. Created ${result.created.length} artefact(s), skipped ${result.skipped.length}.`);
|
|
697
|
+
}
|
|
698
|
+
if (result.defaultSkills.length > 0) {
|
|
699
|
+
const installed = result.defaultSkills.filter((s) => s.status === 'installed').length;
|
|
700
|
+
const skippedSkills = result.defaultSkills.filter((s) => s.status === 'skipped-existing').length;
|
|
701
|
+
this.appendSystemLine(`Default skills: ${installed} installed, ${skippedSkills} already present.`);
|
|
702
|
+
}
|
|
703
|
+
for (const line of lines)
|
|
704
|
+
this.appendSystemLine(line);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
708
|
+
this.appendSystemLine(`/init failed: ${message}`);
|
|
709
|
+
}
|
|
710
|
+
return verdict;
|
|
711
|
+
}
|
|
666
712
|
case 'stub': {
|
|
667
713
|
this.appendSystemLine(verdict.message);
|
|
668
714
|
return verdict;
|
|
@@ -62,6 +62,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
62
62
|
{ name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
|
|
63
63
|
{ name: 'compact', args: '', gloss: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
|
|
64
64
|
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
65
|
+
{ name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
|
|
65
66
|
// Pugi tools
|
|
66
67
|
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
67
68
|
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
@@ -248,6 +249,13 @@ export function parseSlashCommand(input) {
|
|
|
248
249
|
// device flow + audit identity are wired correctly).
|
|
249
250
|
return { kind: 'privacy' };
|
|
250
251
|
}
|
|
252
|
+
case 'init': {
|
|
253
|
+
// β1 Sl11: surface the init flow inside the REPL. Tail args
|
|
254
|
+
// are ignored — the init handler is parameterless today; `pugi
|
|
255
|
+
// init --no-defaults` is the CLI surface for skipping bundled
|
|
256
|
+
// skills.
|
|
257
|
+
return { kind: 'init' };
|
|
258
|
+
}
|
|
251
259
|
case 'compact':
|
|
252
260
|
case 'memory':
|
|
253
261
|
case 'config':
|
package/dist/core/settings.js
CHANGED
|
@@ -47,6 +47,34 @@ const pugiSettingsSchema = z.object({
|
|
|
47
47
|
.optional(),
|
|
48
48
|
})
|
|
49
49
|
.optional(),
|
|
50
|
+
// β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
|
|
51
|
+
// overrides merge against the β1 defaults in
|
|
52
|
+
// `core/engine/budgets.ts::beta1DefaultBudgets`. The schema is
|
|
53
|
+
// intentionally loose at the leaf (positive integers) so a typo lands
|
|
54
|
+
// a deterministic `BudgetConfigError` at `resolveBudget()` instead of
|
|
55
|
+
// a Zod parse error two layers up.
|
|
56
|
+
budgets: z
|
|
57
|
+
.object({
|
|
58
|
+
code: z
|
|
59
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
60
|
+
.optional(),
|
|
61
|
+
fix: z
|
|
62
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
63
|
+
.optional(),
|
|
64
|
+
build: z
|
|
65
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
66
|
+
.optional(),
|
|
67
|
+
plan: z
|
|
68
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
69
|
+
.optional(),
|
|
70
|
+
explain: z
|
|
71
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
72
|
+
.optional(),
|
|
73
|
+
review_triple: z
|
|
74
|
+
.object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
|
|
75
|
+
.optional(),
|
|
76
|
+
})
|
|
77
|
+
.optional(),
|
|
50
78
|
});
|
|
51
79
|
export function loadSettings(root) {
|
|
52
80
|
const settingsPath = resolve(root, '.pugi/settings.json');
|
package/dist/runtime/cli.js
CHANGED
|
@@ -51,7 +51,37 @@ import { dispatchEdit, } from '../core/edits/index.js';
|
|
|
51
51
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
52
52
|
* three are in lockstep.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* β1 housekeeping (#51): defensive semver sanitizer. If a future
|
|
56
|
+
* refactor moves PUGI_CLI_VERSION reading to a JSON import (resolveJson)
|
|
57
|
+
* the npm publish pipeline can leak `workspace:*` from a partially-bumped
|
|
58
|
+
* package.json — `npm publish` rewrites these but a local `pnpm pack`
|
|
59
|
+
* does not, and the failure mode is silently shipping an unsemver
|
|
60
|
+
* version that breaks `pugi --version` JSON consumers. Sanitize at the
|
|
61
|
+
* read site so even a leaked literal lands as a deterministic
|
|
62
|
+
* "0.0.0-unknown" rather than `workspace:*`.
|
|
63
|
+
*/
|
|
64
|
+
function sanitizeSemver(raw) {
|
|
65
|
+
if (typeof raw !== 'string')
|
|
66
|
+
return '0.0.0-unknown';
|
|
67
|
+
const trimmed = raw.trim();
|
|
68
|
+
if (!trimmed)
|
|
69
|
+
return '0.0.0-unknown';
|
|
70
|
+
// Strip a `workspace:` / `npm:` / `file:` protocol prefix that pnpm
|
|
71
|
+
// can emit when a partial publish runs.
|
|
72
|
+
const stripped = trimmed.replace(/^(workspace:|npm:|file:)/, '');
|
|
73
|
+
// Accept anything that begins with major.minor.patch + optional
|
|
74
|
+
// prerelease/build per semver 2.0. Reject `*`, `^x`, `~x`, ranges, etc.
|
|
75
|
+
if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(stripped)) {
|
|
76
|
+
return stripped;
|
|
77
|
+
}
|
|
78
|
+
return '0.0.0-unknown';
|
|
79
|
+
}
|
|
80
|
+
// Main bumped to 0.1.0-beta.9 (PR #430 REPL-hang fix). β1a r1 rebase
|
|
81
|
+
// preserves the main bump and runs it through the β1 sanitizer added
|
|
82
|
+
// here so a future workspace:* leak from a partial publish lands as
|
|
83
|
+
// "0.0.0-unknown" instead of corrupting `pugi --version` JSON output.
|
|
84
|
+
const PUGI_CLI_VERSION = sanitizeSemver("0.1.0-beta.12");
|
|
55
85
|
const handlers = {
|
|
56
86
|
accounts,
|
|
57
87
|
agents: dispatchAgents,
|
|
@@ -618,7 +648,15 @@ function parseArgs(argv) {
|
|
|
618
648
|
// through to the engine. Re-routing here means `pugi <cmd> --help`
|
|
619
649
|
// becomes `pugi help <cmd>` deterministically across the entire
|
|
620
650
|
// command tree.
|
|
621
|
-
|
|
651
|
+
//
|
|
652
|
+
// β1 Tt3 carve-out: commands that ship their OWN `--help` block
|
|
653
|
+
// (login, init, ...) must keep `--help` in their args so the
|
|
654
|
+
// command-local printer fires. Without this carve-out
|
|
655
|
+
// `pugi login --help` produces the global help and the per-variant
|
|
656
|
+
// reference (`--provider device|token|env`) gets lost. The carve-out
|
|
657
|
+
// list mirrors handlers whose source carries an
|
|
658
|
+
// `args.includes('--help')` short-circuit.
|
|
659
|
+
if ((args.includes('--help') || args.includes('-h')) && !COMMAND_LOCAL_HELP.has(command)) {
|
|
622
660
|
return { command: 'help', args: [command], flags, isBareInvocation: false };
|
|
623
661
|
}
|
|
624
662
|
return {
|
|
@@ -628,6 +666,14 @@ function parseArgs(argv) {
|
|
|
628
666
|
isBareInvocation,
|
|
629
667
|
};
|
|
630
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* β1 Tt3: commands that own their `--help` rendering. The bare-help
|
|
671
|
+
* redirect leaves their `--help` arg in place so the command-local
|
|
672
|
+
* printer fires instead of the global summary.
|
|
673
|
+
*/
|
|
674
|
+
const COMMAND_LOCAL_HELP = new Set([
|
|
675
|
+
'login',
|
|
676
|
+
]);
|
|
631
677
|
async function version(_args, flags, _session) {
|
|
632
678
|
const payload = {
|
|
633
679
|
name: 'pugi',
|
|
@@ -764,8 +810,21 @@ async function doctor(_args, flags, _session) {
|
|
|
764
810
|
`Release guard: ${payload.releaseGuard}`,
|
|
765
811
|
].join('\n'));
|
|
766
812
|
}
|
|
767
|
-
|
|
768
|
-
|
|
813
|
+
/**
|
|
814
|
+
* Programmatic init scaffolder. Idempotent — every helper call is a
|
|
815
|
+
* `*_IfMissing` write, so re-running over an existing .pugi/ workspace
|
|
816
|
+
* adds nothing to `created` and the operator sees the "Already
|
|
817
|
+
* initialized" copy. Default skills install is best-effort: failure
|
|
818
|
+
* does not throw, the error is appended to the result via stderr so
|
|
819
|
+
* the slash dispatcher can surface it in the REPL system pane.
|
|
820
|
+
*
|
|
821
|
+
* Callers MUST provide `cwd` explicitly; the function does not read
|
|
822
|
+
* `process.cwd()` so REPL invocations from an arbitrary workspace
|
|
823
|
+
* cannot accidentally scaffold the binary's install directory.
|
|
824
|
+
*/
|
|
825
|
+
export async function scaffoldPugiWorkspace(input) {
|
|
826
|
+
const cwd = input.cwd;
|
|
827
|
+
const log = input.log ?? ((line) => process.stderr.write(line));
|
|
769
828
|
const pugiDir = resolve(cwd, '.pugi');
|
|
770
829
|
const created = [];
|
|
771
830
|
const skipped = [];
|
|
@@ -848,11 +907,11 @@ async function init(_args, flags, _session) {
|
|
|
848
907
|
// alone so re-running `pugi init` after the operator customised one of
|
|
849
908
|
// the defaults does not clobber their edits.
|
|
850
909
|
let defaultSkills = [];
|
|
851
|
-
if (!
|
|
910
|
+
if (!input.noDefaults) {
|
|
852
911
|
try {
|
|
853
912
|
defaultSkills = await installDefaultSkills({
|
|
854
913
|
workspaceRoot: cwd,
|
|
855
|
-
log
|
|
914
|
+
log,
|
|
856
915
|
});
|
|
857
916
|
}
|
|
858
917
|
catch (error) {
|
|
@@ -862,29 +921,46 @@ async function init(_args, flags, _session) {
|
|
|
862
921
|
// the operator sees an unexplained crash. Log the error to stderr
|
|
863
922
|
// and continue — the operator can still install skills manually.
|
|
864
923
|
const message = error instanceof Error ? error.message : String(error);
|
|
865
|
-
|
|
924
|
+
log(`[pugi init] default-skills install failed: ${message}\n`);
|
|
866
925
|
}
|
|
867
926
|
}
|
|
868
|
-
|
|
927
|
+
return {
|
|
869
928
|
status: 'initialized',
|
|
870
929
|
root: cwd,
|
|
871
930
|
created,
|
|
872
931
|
skipped,
|
|
873
932
|
defaultSkills,
|
|
933
|
+
alreadyInitialized: created.length === 0,
|
|
874
934
|
};
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Standalone `pugi init` CLI entry. Thin wrapper around
|
|
938
|
+
* `scaffoldPugiWorkspace` that handles flag plumbing + writeOutput
|
|
939
|
+
* formatting. β1a r1: extracted from the previous inline init so the
|
|
940
|
+
* REPL's `/init` slash can call `scaffoldPugiWorkspace` directly.
|
|
941
|
+
*/
|
|
942
|
+
async function init(_args, flags, _session) {
|
|
943
|
+
const result = await scaffoldPugiWorkspace({
|
|
944
|
+
cwd: process.cwd(),
|
|
945
|
+
noDefaults: flags.noDefaults,
|
|
946
|
+
});
|
|
875
947
|
const defaultSkillLines = flags.noDefaults
|
|
876
948
|
? ['Default skills: skipped (--no-defaults)']
|
|
877
|
-
: defaultSkills.length === 0
|
|
949
|
+
: result.defaultSkills.length === 0
|
|
878
950
|
? ['Default skills: none installed']
|
|
879
951
|
: [
|
|
880
952
|
'Default skills:',
|
|
881
|
-
...defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
|
|
953
|
+
...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
|
|
882
954
|
];
|
|
883
|
-
writeOutput(flags,
|
|
955
|
+
writeOutput(flags, result, [
|
|
884
956
|
'Pugi initialized',
|
|
885
|
-
`Root: ${
|
|
886
|
-
created.length
|
|
887
|
-
|
|
957
|
+
`Root: ${result.root}`,
|
|
958
|
+
result.created.length
|
|
959
|
+
? `Created:\n${result.created.map((path) => ` ${path}`).join('\n')}`
|
|
960
|
+
: 'Created: none',
|
|
961
|
+
result.skipped.length
|
|
962
|
+
? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
|
|
963
|
+
: 'Already present: none',
|
|
888
964
|
...defaultSkillLines,
|
|
889
965
|
].join('\n'));
|
|
890
966
|
}
|
|
@@ -4362,5 +4438,6 @@ export function packageRoot() {
|
|
|
4362
4438
|
export const __test__ = {
|
|
4363
4439
|
sleep,
|
|
4364
4440
|
pollDeviceFlowUntilTerminal,
|
|
4441
|
+
sanitizeSemver,
|
|
4365
4442
|
};
|
|
4366
4443
|
//# sourceMappingURL=cli.js.map
|