@pugi/cli 0.1.0-beta.20 → 0.1.0-beta.22
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/bare-mode/index.js +107 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/engine/native-pugi.js +21 -10
- package/dist/core/engine/prompts.js +30 -2
- package/dist/core/engine/tool-bridge.js +32 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/index.js +1 -1
- package/dist/core/permissions/state.js +55 -0
- package/dist/core/repl/session.js +375 -12
- package/dist/core/repl/slash-commands.js +99 -1
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/runtime/cli.js +386 -1
- package/dist/runtime/commands/doctor.js +8 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/registry.js +8 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tui/compact-banner.js +28 -1
- package/dist/tui/conversation-pane.js +13 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/repl-render.js +9 -1
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +22 -0
- package/package.json +2 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L25 (2026-05-27) — Onboarding marker file.
|
|
3
|
+
*
|
|
4
|
+
* `~/.pugi/.onboarded` is a single, contentless marker. Its existence
|
|
5
|
+
* tells the bare-invocation hint check that the operator has already
|
|
6
|
+
* walked the `/onboarding` wizard at least once, so we no longer print
|
|
7
|
+
* the "Tip: run `pugi onboarding` to configure defaults" line on
|
|
8
|
+
* cold-start. The wizard re-runs cleanly — idempotency lives in the
|
|
9
|
+
* wizard itself, not in the marker.
|
|
10
|
+
*
|
|
11
|
+
* Why a marker file (and not just `~/.pugi/config.json`'s existence)?
|
|
12
|
+
*
|
|
13
|
+
* - The config file is touched the moment ANY surface writes a
|
|
14
|
+
* default — `pugi style terse --persist`, `pugi permissions ask`,
|
|
15
|
+
* `pugi config set …`. Using "config exists" as the proxy for
|
|
16
|
+
* "operator has onboarded" would silence the first-run hint for
|
|
17
|
+
* operators who never saw the wizard.
|
|
18
|
+
*
|
|
19
|
+
* - The marker is explicit: it is written ONLY by the wizard's exit
|
|
20
|
+
* step (or `pugi onboarding --mark-only` for the upgrade-path
|
|
21
|
+
* where we want to suppress the hint without forcing a re-walk).
|
|
22
|
+
*
|
|
23
|
+
* - Removing the marker (`rm ~/.pugi/.onboarded`) re-arms the hint
|
|
24
|
+
* without nuking the operator's accumulated config — useful for
|
|
25
|
+
* QA, support flows, and demo-machine resets.
|
|
26
|
+
*
|
|
27
|
+
* Path resolution mirrors the L6/L18 convention: `PUGI_HOME` env wins,
|
|
28
|
+
* else `~/.pugi`. The marker is touched as an empty file (no JSON, no
|
|
29
|
+
* timestamp payload) — readers MUST treat existence as the only signal
|
|
30
|
+
* so a future change to mtime semantics does not break us.
|
|
31
|
+
*
|
|
32
|
+
* IO contract:
|
|
33
|
+
* - `isOnboarded(env)` — pure read; never throws, returns false on
|
|
34
|
+
* any fs error so a corrupted home dir cannot hide the hint.
|
|
35
|
+
* - `markOnboarded(env)` — best-effort write; creates `<home>/.pugi/`
|
|
36
|
+
* if missing, mode 0o600 on the marker so it never lands in a
|
|
37
|
+
* world-readable backup.
|
|
38
|
+
* - `clearOnboarded(env)` — best-effort delete; absent file is a
|
|
39
|
+
* no-op (not an error). Used by `pugi onboarding --reset` and the
|
|
40
|
+
* spec teardown.
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { resolve } from 'node:path';
|
|
45
|
+
/**
|
|
46
|
+
* Env override for `~/.pugi`. Same convention as L6 / L18 — spec
|
|
47
|
+
* fixtures point this at a temp dir so a real developer machine never
|
|
48
|
+
* lands a stray marker.
|
|
49
|
+
*/
|
|
50
|
+
export const PUGI_HOME_ENV = 'PUGI_HOME';
|
|
51
|
+
/**
|
|
52
|
+
* Marker basename. Hidden (leading dot) so it does not clutter `ls`
|
|
53
|
+
* inside `~/.pugi/` next to `config.json` / `session.json`.
|
|
54
|
+
*/
|
|
55
|
+
const MARKER_BASENAME = '.onboarded';
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the absolute path to the onboarding marker. Exported for the
|
|
58
|
+
* spec; production callers go through `isOnboarded` / `markOnboarded`.
|
|
59
|
+
*/
|
|
60
|
+
export function onboardingMarkerPath(env = process.env) {
|
|
61
|
+
const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
|
|
62
|
+
return resolve(home, MARKER_BASENAME);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* True when the marker exists. Pure read. Defensive: any fs error
|
|
66
|
+
* (race with deletion, permission flip) degrades to `false` — printing
|
|
67
|
+
* the hint twice is harmless, silently swallowing the wizard would
|
|
68
|
+
* surprise the operator.
|
|
69
|
+
*/
|
|
70
|
+
export function isOnboarded(env = process.env) {
|
|
71
|
+
try {
|
|
72
|
+
return existsSync(onboardingMarkerPath(env));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Touch the marker. Creates `<home>/.pugi/` if missing. Idempotent —
|
|
80
|
+
* re-touching an existing marker is a no-op for the consumer (the file
|
|
81
|
+
* was already there; the hint was already suppressed).
|
|
82
|
+
*
|
|
83
|
+
* Best-effort: a write failure is swallowed because the wizard already
|
|
84
|
+
* completed its real work (mode + style + telemetry were persisted).
|
|
85
|
+
* The worst case is a redundant hint on the next `pugi` invocation —
|
|
86
|
+
* preferable to crashing the freshly-completed wizard with a stat EIO.
|
|
87
|
+
*/
|
|
88
|
+
export function markOnboarded(env = process.env) {
|
|
89
|
+
const path = onboardingMarkerPath(env);
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(resolve(path, '..'), { recursive: true });
|
|
92
|
+
writeFileSync(path, '', { encoding: 'utf8', mode: 0o600 });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// intentionally swallowed — see header
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Remove the marker. Used by `pugi onboarding --reset` (and the spec
|
|
100
|
+
* teardown). Absent file is a no-op; any other fs error is swallowed
|
|
101
|
+
* so a permission glitch never leaks out of the reset surface.
|
|
102
|
+
*/
|
|
103
|
+
export function clearOnboarded(env = process.env) {
|
|
104
|
+
try {
|
|
105
|
+
rmSync(onboardingMarkerPath(env), { force: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// intentionally swallowed — see header
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=marker.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L25 (2026-05-27) — Telemetry consent state.
|
|
3
|
+
*
|
|
4
|
+
* The onboarding wizard's Step 5 asks the operator for telemetry
|
|
5
|
+
* consent. We persist the verdict in the user-tier
|
|
6
|
+
* `~/.pugi/config.json::telemetry` field so a future REPL boot can
|
|
7
|
+
* read it without re-asking. Choices mirror `core/settings.ts`'s
|
|
8
|
+
* `privacy.telemetry` enum:
|
|
9
|
+
*
|
|
10
|
+
* - `off` — no telemetry of any kind (default).
|
|
11
|
+
* - `anonymous` — counts + error categories only, no payloads.
|
|
12
|
+
* - `community` — anonymous + opt-in skill/usage panels.
|
|
13
|
+
*
|
|
14
|
+
* This module is intentionally narrow: it only owns the `telemetry`
|
|
15
|
+
* key inside `~/.pugi/config.json`. The full settings parsing lives in
|
|
16
|
+
* `core/settings.ts` (workspace-tier `.pugi/settings.json`); we do NOT
|
|
17
|
+
* route through it here because:
|
|
18
|
+
*
|
|
19
|
+
* 1. The settings schema is workspace-scoped — its file path is
|
|
20
|
+
* `<root>/.pugi/settings.json`, not `~/.pugi/config.json`.
|
|
21
|
+
* 2. The wizard records a user-level default that workspace settings
|
|
22
|
+
* can later override. Mixing the two would conflate scope.
|
|
23
|
+
* 3. Read-modify-write on a partial JSON file is the same pattern
|
|
24
|
+
* L6 / L18 use for adjacent keys — keeping it self-contained
|
|
25
|
+
* preserves the "one module, one key" invariant.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { dirname, resolve } from 'node:path';
|
|
30
|
+
import { PUGI_HOME_ENV } from './marker.js';
|
|
31
|
+
export const TELEMETRY_CHOICES = Object.freeze([
|
|
32
|
+
'off',
|
|
33
|
+
'anonymous',
|
|
34
|
+
'community',
|
|
35
|
+
]);
|
|
36
|
+
export const DEFAULT_TELEMETRY = 'off';
|
|
37
|
+
/**
|
|
38
|
+
* Path to the user-tier config. Mirrors `userConfigPath()` from L18
|
|
39
|
+
* `output-style/state.ts` — duplicated here (not imported) to keep the
|
|
40
|
+
* marker + telemetry module self-contained. Any future drift between
|
|
41
|
+
* the two would surface a spec failure: both modules read the same
|
|
42
|
+
* file in the spec sandbox.
|
|
43
|
+
*/
|
|
44
|
+
export function telemetryConfigPath(env = process.env) {
|
|
45
|
+
const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
|
|
46
|
+
return resolve(home, 'config.json');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Type guard for arbitrary string input (CLI argv, config.json
|
|
50
|
+
* deserialisation). Returns false for any non-string or out-of-set
|
|
51
|
+
* value so a malformed config degrades to the default verdict.
|
|
52
|
+
*/
|
|
53
|
+
export function isTelemetryChoice(value) {
|
|
54
|
+
return (typeof value === 'string'
|
|
55
|
+
&& TELEMETRY_CHOICES.includes(value));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Read the persisted telemetry verdict. Returns the default (`'off'`)
|
|
59
|
+
* when the file is absent, empty, malformed, or holds an unknown
|
|
60
|
+
* value. Never throws — the wizard re-asks every time it runs, so a
|
|
61
|
+
* defensive read is the right posture.
|
|
62
|
+
*/
|
|
63
|
+
export function readTelemetryChoice(io = {}) {
|
|
64
|
+
const config = readConfigFile(telemetryConfigPath(io.env ?? process.env));
|
|
65
|
+
return isTelemetryChoice(config.telemetry) ? config.telemetry : DEFAULT_TELEMETRY;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Persist the telemetry verdict. Read-modify-write preserves any
|
|
69
|
+
* neighbouring keys (`outputStyle`, `defaultPermissionMode`, …) the
|
|
70
|
+
* other tier-state modules own.
|
|
71
|
+
*/
|
|
72
|
+
export function writeTelemetryChoice(choice, io = {}) {
|
|
73
|
+
const path = telemetryConfigPath(io.env ?? process.env);
|
|
74
|
+
const config = readConfigFile(path);
|
|
75
|
+
config.telemetry = choice;
|
|
76
|
+
writeConfigFile(path, config);
|
|
77
|
+
}
|
|
78
|
+
function readConfigFile(path) {
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
return {};
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
raw = readFileSync(path, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
if (raw.trim().length === 0)
|
|
89
|
+
return {};
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
98
|
+
return {};
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
function writeConfigFile(path, config) {
|
|
102
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
103
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
mode: 0o600,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=telemetry-state.js.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L18 (2026-05-27) — Output-style presets.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of Claude Code's `/output-style` surface: a small closed set of
|
|
5
|
+
* named voice presets the operator can flip between at session start so
|
|
6
|
+
* the model's prose lands in the register they prefer. The preset
|
|
7
|
+
* compiles into an `<output-style>` rule block appended to the engine
|
|
8
|
+
* system prompt; tool-use / code-block formatting / file edits are NOT
|
|
9
|
+
* affected — the preset only steers prose register.
|
|
10
|
+
*
|
|
11
|
+
* Design contract:
|
|
12
|
+
*
|
|
13
|
+
* - The catalogue is intentionally tiny (5 entries) so the operator
|
|
14
|
+
* can hold the full surface in working memory. Adding entries means
|
|
15
|
+
* adding a row in `OUTPUT_STYLES` plus a spec assertion; there is
|
|
16
|
+
* no plugin surface today.
|
|
17
|
+
*
|
|
18
|
+
* - `default` is the only preset that emits NO rule block. The
|
|
19
|
+
* "current Pugi voice" already lives in the base engine prompt
|
|
20
|
+
* (jargon ban, brand voice, terse register), so re-stating it
|
|
21
|
+
* under `<output-style>` would double the model's instruction load
|
|
22
|
+
* for the most-common case. Other presets emit the block.
|
|
23
|
+
*
|
|
24
|
+
* - Rule-block prose stays terse and operator-grade (brandbook §08).
|
|
25
|
+
* No friendly hedging, no AI-assistant framing. The bullets are
|
|
26
|
+
* the model's contract; the section title carries the preset name
|
|
27
|
+
* so the model can self-correct mid-turn if it drifts ("I am in
|
|
28
|
+
* terse mode → drop articles").
|
|
29
|
+
*
|
|
30
|
+
* - The Russian-formal preset uses вы-form explicitly. Russian/
|
|
31
|
+
* Ukrainian chat is permitted by the base voice contract; this
|
|
32
|
+
* preset hardens the register for B2B / enterprise demo flows
|
|
33
|
+
* where ты-form reads as too casual.
|
|
34
|
+
*
|
|
35
|
+
* - The Casual preset RELAXES the jargon ban — contractions, jokes,
|
|
36
|
+
* informal phrasing are allowed. It does NOT lift the brand-voice
|
|
37
|
+
* em-dash / emoji ban; those are typographic, not register, and
|
|
38
|
+
* remain off across every preset.
|
|
39
|
+
*
|
|
40
|
+
* Test surface: `test/commands/output-style-presets.spec.ts` exercises
|
|
41
|
+
* the catalogue invariants (5 entries, unique slugs, every non-default
|
|
42
|
+
* preset emits a non-empty block, the block starts with the expected
|
|
43
|
+
* marker so the engine prompt appender can locate it for stripping).
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The closed list of preset slugs in catalogue order. Mirror used by
|
|
47
|
+
* the CLI surface (`/style` table, `pugi style --list`) so the
|
|
48
|
+
* operator sees presets in a stable order regardless of catalogue
|
|
49
|
+
* iteration order.
|
|
50
|
+
*/
|
|
51
|
+
export const OUTPUT_STYLE_SLUGS = Object.freeze([
|
|
52
|
+
'default',
|
|
53
|
+
'terse',
|
|
54
|
+
'explanatory',
|
|
55
|
+
'russian-formal',
|
|
56
|
+
'casual',
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* Default slug used when no workspace-/user-level preference is set.
|
|
60
|
+
* Exported so `state.ts` and the CLI handler share one constant.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_OUTPUT_STYLE = 'default';
|
|
63
|
+
/**
|
|
64
|
+
* Catalogue keyed by slug. Frozen so callers cannot mutate the
|
|
65
|
+
* shared rows; the CLI handler returns slugs, not preset references,
|
|
66
|
+
* to keep the boundary clean.
|
|
67
|
+
*/
|
|
68
|
+
export const OUTPUT_STYLES = Object.freeze({
|
|
69
|
+
default: Object.freeze({
|
|
70
|
+
slug: 'default',
|
|
71
|
+
title: 'Default',
|
|
72
|
+
gloss: 'Current Pugi voice (no override). Base engine prompt rules apply unchanged.',
|
|
73
|
+
rules: Object.freeze([]),
|
|
74
|
+
}),
|
|
75
|
+
terse: Object.freeze({
|
|
76
|
+
slug: 'terse',
|
|
77
|
+
title: 'Terse',
|
|
78
|
+
gloss: 'Fragments, dropped articles, one short sentence per turn.',
|
|
79
|
+
rules: Object.freeze([
|
|
80
|
+
'Drop articles, fillers, hedging',
|
|
81
|
+
'1 short sentence per turn for prose answers',
|
|
82
|
+
'Code blocks unchanged — never abbreviate code',
|
|
83
|
+
'Quote errors verbatim with no paraphrase',
|
|
84
|
+
]),
|
|
85
|
+
}),
|
|
86
|
+
explanatory: Object.freeze({
|
|
87
|
+
slug: 'explanatory',
|
|
88
|
+
title: 'Explanatory',
|
|
89
|
+
gloss: 'Verbose, walks reasoning step by step, links concepts.',
|
|
90
|
+
rules: Object.freeze([
|
|
91
|
+
'Explain reasoning, not just the conclusion',
|
|
92
|
+
'Cite relevant files + line numbers when grounding claims',
|
|
93
|
+
'Link adjacent concepts the operator may want to chase',
|
|
94
|
+
'Code blocks unchanged — annotate around, not inside',
|
|
95
|
+
]),
|
|
96
|
+
}),
|
|
97
|
+
'russian-formal': Object.freeze({
|
|
98
|
+
slug: 'russian-formal',
|
|
99
|
+
title: 'Russian formal',
|
|
100
|
+
gloss: 'Russian вы-form, professional register, no slang.',
|
|
101
|
+
rules: Object.freeze([
|
|
102
|
+
'Pisat\' otvety po-russki (Russian prose; ASCII transliteration permitted in this rule block only)',
|
|
103
|
+
'Address the operator using вы-form, never ты',
|
|
104
|
+
'No slang, no contractions of Russian forms',
|
|
105
|
+
'Code blocks + identifiers stay in English unchanged',
|
|
106
|
+
'Error messages quoted verbatim in the original language',
|
|
107
|
+
]),
|
|
108
|
+
}),
|
|
109
|
+
casual: Object.freeze({
|
|
110
|
+
slug: 'casual',
|
|
111
|
+
title: 'Casual',
|
|
112
|
+
gloss: 'Informal register, contractions OK, dry jokes welcome.',
|
|
113
|
+
rules: Object.freeze([
|
|
114
|
+
'Contractions allowed (it\'s, don\'t, you\'re)',
|
|
115
|
+
'Dry, deadpan jokes welcome when they do not displace signal',
|
|
116
|
+
'No em-dashes, no emoji — typographic rules unchanged',
|
|
117
|
+
'Stay terse — casual is register, not verbosity',
|
|
118
|
+
]),
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* Type-narrowing predicate. Used by the slash-command parser + state
|
|
123
|
+
* loader so an unknown string from operator input or a stale config
|
|
124
|
+
* file degrades to the default preset instead of crashing.
|
|
125
|
+
*/
|
|
126
|
+
export function isOutputStyleSlug(value) {
|
|
127
|
+
return (typeof value === 'string'
|
|
128
|
+
&& OUTPUT_STYLE_SLUGS.includes(value));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Compile a preset into the `<output-style>` rule block injected at
|
|
132
|
+
* the tail of the engine system prompt.
|
|
133
|
+
*
|
|
134
|
+
* Returns empty string when the preset is `default` (or any preset
|
|
135
|
+
* with an empty rules array). Empty string is a load-bearing signal —
|
|
136
|
+
* the engine prompt appender uses it to skip injection entirely so
|
|
137
|
+
* the model sees a clean prompt for the default register.
|
|
138
|
+
*
|
|
139
|
+
* The block opens with `<output-style>` and closes with `</output-style>`
|
|
140
|
+
* (XML-shaped marker, matching the engine prompt's existing `<intent>`
|
|
141
|
+
* grammar). The `Active style:` line gives the model a self-correction
|
|
142
|
+
* anchor when it drifts mid-turn.
|
|
143
|
+
*/
|
|
144
|
+
export function compileStyleBlock(slug) {
|
|
145
|
+
const preset = OUTPUT_STYLES[slug];
|
|
146
|
+
if (preset.rules.length === 0)
|
|
147
|
+
return '';
|
|
148
|
+
const lines = [];
|
|
149
|
+
lines.push('<output-style>');
|
|
150
|
+
lines.push(` Active style: ${preset.slug}`);
|
|
151
|
+
for (const rule of preset.rules) {
|
|
152
|
+
lines.push(` - ${rule}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push('</output-style>');
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Render the preset catalogue as a plain-text table for the `/style`
|
|
159
|
+
* + `pugi style` surfaces. Marks the active slug with `*` so the
|
|
160
|
+
* operator can see at a glance which preset is in effect.
|
|
161
|
+
*
|
|
162
|
+
* Pure renderer (no fs, no env). Identical text is emitted from both
|
|
163
|
+
* the slash dispatcher and the top-level CLI command so operators
|
|
164
|
+
* trained on one surface read the same table on the other.
|
|
165
|
+
*/
|
|
166
|
+
export function renderStyleTable(active) {
|
|
167
|
+
const slugWidth = Math.max('NAME'.length, ...OUTPUT_STYLE_SLUGS.map((slug) => slug.length));
|
|
168
|
+
const header = `${'NAME'.padEnd(slugWidth)} GLOSS`;
|
|
169
|
+
const rows = OUTPUT_STYLE_SLUGS.map((slug) => {
|
|
170
|
+
const preset = OUTPUT_STYLES[slug];
|
|
171
|
+
const marker = slug === active ? '*' : ' ';
|
|
172
|
+
return `${marker} ${slug.padEnd(slugWidth)} ${preset.gloss}`;
|
|
173
|
+
});
|
|
174
|
+
return [header, ...rows].join('\n');
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=presets.js.map
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L18 (2026-05-27) — Output-style state persistence.
|
|
3
|
+
*
|
|
4
|
+
* Two-tier storage:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Workspace** — `<workspaceRoot>/.pugi/config.json`. Set by
|
|
7
|
+
* `/style <name>` from inside the REPL or `pugi style <name>`
|
|
8
|
+
* without `--persist`. Overrides the user default for the
|
|
9
|
+
* current workspace only. Survives sessions because the same
|
|
10
|
+
* `.pugi/` survives sessions.
|
|
11
|
+
*
|
|
12
|
+
* 2. **User default** — `~/.pugi/config.json` (PUGI_HOME-aware).
|
|
13
|
+
* Set by `pugi style <name> --persist` or
|
|
14
|
+
* `/style <name> --persist`. Applies to every workspace that
|
|
15
|
+
* has no workspace-level override.
|
|
16
|
+
*
|
|
17
|
+
* Precedence (highest → lowest):
|
|
18
|
+
*
|
|
19
|
+
* workspace value > user value > DEFAULT_OUTPUT_STYLE ('default')
|
|
20
|
+
*
|
|
21
|
+
* Both files live under the same `pugi-config-v1` JSON envelope as
|
|
22
|
+
* other settings (permissionMode, privacy, model, preferredEndpoint).
|
|
23
|
+
* The schema is intentionally NOT shared with `runtime/commands/config.ts`'s
|
|
24
|
+
* strict Zod schema — `outputStyle` is read/written ONLY through this
|
|
25
|
+
* module so `pugi config set outputStyle=…` is NOT a supported path
|
|
26
|
+
* (it would silently bypass the slug validator). Operators get a
|
|
27
|
+
* single surface: `/style` + `pugi style`.
|
|
28
|
+
*
|
|
29
|
+
* File layout (one config.json, multiple keys; this module owns the
|
|
30
|
+
* `outputStyle` key only):
|
|
31
|
+
*
|
|
32
|
+
* {
|
|
33
|
+
* "permissionMode": "ask",
|
|
34
|
+
* "outputStyle": "terse",
|
|
35
|
+
* ...
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* The reader tolerates:
|
|
39
|
+
* - missing file (returns the default slug),
|
|
40
|
+
* - empty file (returns the default slug),
|
|
41
|
+
* - malformed JSON (returns the default slug — DO NOT crash REPL
|
|
42
|
+
* boot because of a hand-edited config),
|
|
43
|
+
* - unknown slug (returns the default slug + emits no error; the
|
|
44
|
+
* operator can `/style` to see the table and re-set).
|
|
45
|
+
*
|
|
46
|
+
* The writer is a read-modify-write to preserve neighbouring keys
|
|
47
|
+
* (permissionMode etc.) — overwriting the whole file would clobber
|
|
48
|
+
* the other tier's settings.
|
|
49
|
+
*
|
|
50
|
+
* Test surface: `test/commands/output-style-state.spec.ts` exercises
|
|
51
|
+
* precedence, malformed-config tolerance, persistence across reads,
|
|
52
|
+
* the `--persist` (user-default) path, and reset semantics.
|
|
53
|
+
*/
|
|
54
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
55
|
+
import { homedir } from 'node:os';
|
|
56
|
+
import { dirname, resolve } from 'node:path';
|
|
57
|
+
import { DEFAULT_OUTPUT_STYLE, isOutputStyleSlug, } from './presets.js';
|
|
58
|
+
/**
|
|
59
|
+
* Env override for `~/.pugi` so the spec can sandbox both tiers
|
|
60
|
+
* without touching the developer's real config. Matches the existing
|
|
61
|
+
* `runtime/commands/config.ts` convention.
|
|
62
|
+
*/
|
|
63
|
+
export const PUGI_HOME_ENV = 'PUGI_HOME';
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the active output style for the workspace, applying the
|
|
66
|
+
* precedence ladder (workspace > user > default).
|
|
67
|
+
*
|
|
68
|
+
* Pure read. Never writes, never throws — every IO failure degrades
|
|
69
|
+
* to the default slug. The function returns the source label too so
|
|
70
|
+
* the CLI surface can show the operator where the value came from.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveOutputStyle(io) {
|
|
73
|
+
const workspaceSlug = readSlugFromFile(workspaceConfigPath(io.workspaceRoot));
|
|
74
|
+
if (workspaceSlug)
|
|
75
|
+
return { slug: workspaceSlug, source: 'workspace' };
|
|
76
|
+
const userSlug = readSlugFromFile(userConfigPath(io.env ?? process.env));
|
|
77
|
+
if (userSlug)
|
|
78
|
+
return { slug: userSlug, source: 'user' };
|
|
79
|
+
return { slug: DEFAULT_OUTPUT_STYLE, source: 'default' };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Write `slug` to the workspace tier. Creates `<workspaceRoot>/.pugi/`
|
|
83
|
+
* if missing. Preserves neighbouring config keys via read-modify-write.
|
|
84
|
+
*/
|
|
85
|
+
export function setWorkspaceOutputStyle(slug, io) {
|
|
86
|
+
writeSlugToFile(workspaceConfigPath(io.workspaceRoot), slug);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Write `slug` to the user tier (`~/.pugi/config.json`).
|
|
90
|
+
*
|
|
91
|
+
* Mirrors the workspace writer's read-modify-write so the user's
|
|
92
|
+
* `permissionMode` / `privacy` / `model` keys survive a style flip.
|
|
93
|
+
*/
|
|
94
|
+
export function setUserOutputStyle(slug, io) {
|
|
95
|
+
writeSlugToFile(userConfigPath(io.env ?? process.env), slug);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Clear the workspace tier's `outputStyle` key. The user tier (and
|
|
99
|
+
* therefore the eventual resolved style) is left untouched.
|
|
100
|
+
*
|
|
101
|
+
* Used by `/style --reset` so the operator can revert a workspace
|
|
102
|
+
* override without nuking the rest of their workspace config.
|
|
103
|
+
*/
|
|
104
|
+
export function clearWorkspaceOutputStyle(io) {
|
|
105
|
+
clearSlugInFile(workspaceConfigPath(io.workspaceRoot));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Clear the user tier's `outputStyle` key. Lower-blast-radius reset
|
|
109
|
+
* for operators who want every workspace to fall back to `default`
|
|
110
|
+
* unless an explicit workspace value is set.
|
|
111
|
+
*/
|
|
112
|
+
export function clearUserOutputStyle(io) {
|
|
113
|
+
clearSlugInFile(userConfigPath(io.env ?? process.env));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Workspace config path. Exported for the spec; production callers
|
|
117
|
+
* should use the `setWorkspace…` / `resolveOutputStyle` helpers.
|
|
118
|
+
*/
|
|
119
|
+
export function workspaceConfigPath(workspaceRoot) {
|
|
120
|
+
return resolve(workspaceRoot, '.pugi', 'config.json');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* User config path resolved against `PUGI_HOME` (or `~/.pugi`).
|
|
124
|
+
* Exported for the spec.
|
|
125
|
+
*/
|
|
126
|
+
export function userConfigPath(env = process.env) {
|
|
127
|
+
const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
|
|
128
|
+
return resolve(home, 'config.json');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read + parse a config file. Returns an empty object on any IO or
|
|
132
|
+
* parse error. Caller-provided JSON must be a plain object; arrays /
|
|
133
|
+
* scalars / null are treated as "no config" so a hand-edited file
|
|
134
|
+
* never crashes the REPL.
|
|
135
|
+
*/
|
|
136
|
+
function readConfigFile(path) {
|
|
137
|
+
if (!existsSync(path))
|
|
138
|
+
return {};
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = readFileSync(path, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
if (raw.trim().length === 0)
|
|
147
|
+
return {};
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
156
|
+
return {};
|
|
157
|
+
return parsed;
|
|
158
|
+
}
|
|
159
|
+
function writeConfigFile(path, config) {
|
|
160
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
161
|
+
// 0o600 mirrors `runtime/commands/config.ts` — the config file may
|
|
162
|
+
// hold `preferredEndpoint` URLs that should not be world-readable.
|
|
163
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
|
|
164
|
+
encoding: 'utf8',
|
|
165
|
+
mode: 0o600,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function readSlugFromFile(path) {
|
|
169
|
+
const config = readConfigFile(path);
|
|
170
|
+
const candidate = config.outputStyle;
|
|
171
|
+
return isOutputStyleSlug(candidate) ? candidate : null;
|
|
172
|
+
}
|
|
173
|
+
function writeSlugToFile(path, slug) {
|
|
174
|
+
const config = readConfigFile(path);
|
|
175
|
+
config.outputStyle = slug;
|
|
176
|
+
writeConfigFile(path, config);
|
|
177
|
+
}
|
|
178
|
+
function clearSlugInFile(path) {
|
|
179
|
+
const config = readConfigFile(path);
|
|
180
|
+
if (!('outputStyle' in config))
|
|
181
|
+
return;
|
|
182
|
+
delete config.outputStyle;
|
|
183
|
+
writeConfigFile(path, config);
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
|
|
15
15
|
export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
|
|
16
16
|
export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
|
|
17
|
-
export { getCurrentMode, getGlobalDefaultMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, } from './state.js';
|
|
17
|
+
export { getCurrentMode, getGlobalDefaultMode, getPreviousMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, setPreviousMode, } from './state.js';
|
|
18
18
|
//# sourceMappingURL=index.js.map
|
|
@@ -27,6 +27,13 @@ const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
|
|
|
27
27
|
const sessionStateSchema = z
|
|
28
28
|
.object({
|
|
29
29
|
permissionMode: permissionModeEnum.optional(),
|
|
30
|
+
/**
|
|
31
|
+
* Leak L7: snapshot of the mode that was active immediately BEFORE
|
|
32
|
+
* the operator typed `/plan` (or `/plan <prompt>`). `/plan --back`
|
|
33
|
+
* pops this snapshot and restores it. Cleared after a successful
|
|
34
|
+
* pop so a second `/plan --back` does not double-revert.
|
|
35
|
+
*/
|
|
36
|
+
previousPermissionMode: permissionModeEnum.optional(),
|
|
30
37
|
})
|
|
31
38
|
.partial()
|
|
32
39
|
.passthrough();
|
|
@@ -90,6 +97,54 @@ export function setCurrentMode(workspaceRoot, mode) {
|
|
|
90
97
|
writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
91
98
|
renameSync(tmpPath, path);
|
|
92
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Leak L7 — read the snapshot of the mode that was active before the
|
|
102
|
+
* most-recent `/plan` (or `pugi plan`) entry. Returns null when the
|
|
103
|
+
* file is absent OR the field is unset. Same defensive behaviour as
|
|
104
|
+
* `getCurrentMode`: a malformed session file never breaks the slash
|
|
105
|
+
* command — the worst case is `/plan --back` reports "no previous
|
|
106
|
+
* mode to restore" and the operator picks the target mode explicitly.
|
|
107
|
+
*/
|
|
108
|
+
export function getPreviousMode(workspaceRoot) {
|
|
109
|
+
const path = sessionStatePath(workspaceRoot);
|
|
110
|
+
if (!existsSync(path))
|
|
111
|
+
return null;
|
|
112
|
+
try {
|
|
113
|
+
const raw = readFileSync(path, 'utf8');
|
|
114
|
+
const parsed = sessionStateSchema.parse(JSON.parse(raw));
|
|
115
|
+
return isPermissionMode(parsed.previousPermissionMode)
|
|
116
|
+
? parsed.previousPermissionMode
|
|
117
|
+
: null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Leak L7 — record the mode that was active immediately before the
|
|
125
|
+
* operator switched to plan. The runtime calls this AT `/plan` entry
|
|
126
|
+
* with the current mode (whatever `resolveMode` returned). Atomic
|
|
127
|
+
* tmp+rename keeps the snapshot consistent if the process is killed
|
|
128
|
+
* mid-write. Pass `null` to clear the snapshot (used after a
|
|
129
|
+
* successful `/plan --back` so a second `--back` does not loop).
|
|
130
|
+
*/
|
|
131
|
+
export function setPreviousMode(workspaceRoot, mode) {
|
|
132
|
+
const path = sessionStatePath(workspaceRoot);
|
|
133
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
134
|
+
const existing = existsSync(path)
|
|
135
|
+
? safeParseObject(readFileSync(path, 'utf8'))
|
|
136
|
+
: {};
|
|
137
|
+
const next = { ...existing };
|
|
138
|
+
if (mode === null) {
|
|
139
|
+
delete next.previousPermissionMode;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
next.previousPermissionMode = mode;
|
|
143
|
+
}
|
|
144
|
+
const tmpPath = `${path}.tmp`;
|
|
145
|
+
writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
146
|
+
renameSync(tmpPath, path);
|
|
147
|
+
}
|
|
93
148
|
/**
|
|
94
149
|
* Read `~/.pugi/config.json::defaultPermissionMode`. Returns null when
|
|
95
150
|
* the file is absent / the field is unset; same defensive behaviour
|