@pugi/cli 0.1.0-beta.21 → 0.1.0-beta.23

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 (63) hide show
  1. package/dist/core/auth/env-provider.js +238 -0
  2. package/dist/core/bare-mode/index.js +107 -0
  3. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  4. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  5. package/dist/core/engine/native-pugi.js +55 -11
  6. package/dist/core/engine/prompts.js +30 -2
  7. package/dist/core/engine/tool-bridge.js +32 -0
  8. package/dist/core/feedback/queue.js +177 -0
  9. package/dist/core/feedback/submitter.js +145 -0
  10. package/dist/core/onboarding/marker.js +111 -0
  11. package/dist/core/onboarding/telemetry-state.js +108 -0
  12. package/dist/core/output-style/presets.js +176 -0
  13. package/dist/core/output-style/state.js +185 -0
  14. package/dist/core/permissions/index.js +1 -1
  15. package/dist/core/permissions/state.js +55 -0
  16. package/dist/core/pugi-md/context-injector.js +76 -0
  17. package/dist/core/pugi-md/walk-up.js +207 -0
  18. package/dist/core/release-notes/parser.js +241 -0
  19. package/dist/core/release-notes/state.js +116 -0
  20. package/dist/core/repl/session.js +482 -12
  21. package/dist/core/repl/slash-commands.js +134 -1
  22. package/dist/core/repl/workspace-context.js +22 -0
  23. package/dist/core/share/formatter.js +271 -0
  24. package/dist/core/share/redactor.js +221 -0
  25. package/dist/core/share/uploader.js +267 -0
  26. package/dist/core/theme/context.js +91 -0
  27. package/dist/core/theme/presets.js +228 -0
  28. package/dist/core/theme/state.js +181 -0
  29. package/dist/core/todos/invariant.js +10 -0
  30. package/dist/core/todos/state.js +177 -0
  31. package/dist/core/vim/keymap.js +288 -0
  32. package/dist/core/vim/state.js +92 -0
  33. package/dist/runtime/cli.js +603 -15
  34. package/dist/runtime/commands/doctor.js +21 -0
  35. package/dist/runtime/commands/feedback.js +184 -0
  36. package/dist/runtime/commands/onboarding.js +275 -0
  37. package/dist/runtime/commands/plan.js +143 -0
  38. package/dist/runtime/commands/release-notes.js +229 -0
  39. package/dist/runtime/commands/share.js +316 -0
  40. package/dist/runtime/commands/stickers.js +82 -0
  41. package/dist/runtime/commands/style.js +194 -0
  42. package/dist/runtime/commands/theme.js +196 -0
  43. package/dist/runtime/commands/vim.js +140 -0
  44. package/dist/runtime/version.js +1 -1
  45. package/dist/tools/registry.js +8 -0
  46. package/dist/tools/todo-write.js +184 -0
  47. package/dist/tui/compact-banner.js +28 -1
  48. package/dist/tui/conversation-pane.js +13 -0
  49. package/dist/tui/doctor-table.js +32 -17
  50. package/dist/tui/feedback-prompt.js +156 -0
  51. package/dist/tui/onboarding-wizard.js +240 -0
  52. package/dist/tui/repl-render.js +26 -3
  53. package/dist/tui/repl.js +9 -1
  54. package/dist/tui/stickers-art.js +136 -0
  55. package/dist/tui/style-table.js +28 -0
  56. package/dist/tui/theme-table.js +29 -0
  57. package/dist/tui/vim-input.js +267 -0
  58. package/package.json +2 -2
  59. package/dist/core/engine/compaction-hook.js +0 -154
  60. package/dist/core/init/scaffold.js +0 -195
  61. package/dist/core/repl/codebase-survey.js +0 -308
  62. package/dist/core/repl/init-interview.js +0 -457
  63. package/dist/core/repl/onboarding-state.js +0 -297
@@ -49,6 +49,8 @@ import { probeMcp } from '../../core/diagnostics/probes/mcp.js';
49
49
  import { probeConfig } from '../../core/diagnostics/probes/config.js';
50
50
  import { probeSession } from '../../core/diagnostics/probes/session.js';
51
51
  import { probeDenialTracking } from '../../core/diagnostics/probes/denial-tracking.js';
52
+ import { probeBareMode } from '../../core/diagnostics/probes/bare-mode.js';
53
+ import { probePugiMdHierarchy } from '../../core/diagnostics/probes/pugi-md.js';
52
54
  /**
53
55
  * Default API URL when no PUGI_API_URL env override is set. Mirrors
54
56
  * the constant in `core/credentials.ts` (kept local to avoid an
@@ -206,6 +208,25 @@ export function buildDefaultProbes(ctx, options = {}) {
206
208
  ...(options.denialTracking ? { tracker: options.denialTracking } : {}),
207
209
  }),
208
210
  },
211
+ // Leak L22 (2026-05-27): BARE MODE row. Always present so the JSON
212
+ // schema stays stable; status flips to `ok` when `--bare` or
213
+ // `PUGI_BARE=1` is active, otherwise `skipped`.
214
+ {
215
+ name: 'BARE MODE',
216
+ run: async () => probeBareMode({ env: ctx.env }),
217
+ },
218
+ // Leak L32 (2026-05-27): PUGI.md HIERARCHY row. Reports how many
219
+ // ambient `PUGI.md` / `CLAUDE.md` files the cwd → homedir walk
220
+ // discovered, and the closest path. `skipped` when bare mode is
221
+ // active (walk disabled) or zero files found.
222
+ {
223
+ name: 'PUGI.md HIERARCHY',
224
+ run: async () => probePugiMdHierarchy({
225
+ cwd: ctx.cwd,
226
+ homedir: ctx.home,
227
+ env: ctx.env,
228
+ }),
229
+ },
209
230
  ];
210
231
  return probes;
211
232
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `pugi feedback` + `/feedback` slash — Leak L21 (2026-05-27).
3
+ *
4
+ * In-CLI feedback collector. Parity with Claude Code's `/feedback`
5
+ * built-in. The operator never has to leave the terminal to file a
6
+ * bug / feature / general comment / praise. The wizard collects:
7
+ *
8
+ * 1. category (bug / feature / general / praise)
9
+ * 2. rating (1-5)
10
+ * 3. comment (multi-line free text)
11
+ * 4. optional redacted session context (last 5 turns)
12
+ * 5. confirm (final y/n)
13
+ *
14
+ * # Module contract
15
+ *
16
+ * - This file owns the WIRING from the CLI surface (TTY mount,
17
+ * non-TTY JSON, slash dispatcher) to the queue + submitter
18
+ * modules. The corpus + redactor + queue persistence live in
19
+ * `core/feedback/{queue.ts,submitter.ts}`. The Ink prompt lives
20
+ * in `tui/feedback-prompt.tsx`. Both have zero coupling to the
21
+ * CLI dispatch surface.
22
+ *
23
+ * - `runFeedbackCommand` is the single entry point. Both the top-
24
+ * level `pugi feedback` handler в `runtime/cli.ts` AND the in-REPL
25
+ * `/feedback` slash dispatcher call it. The function returns the
26
+ * resolved `FeedbackRunResult` so the slash dispatcher can route
27
+ * the outcome message to the REPL's system pane without re-prompting.
28
+ *
29
+ * - Exit code is ALWAYS 0. Feedback is a brand surface — never a
30
+ * gate. Failures land as result variants; the wrapper never
31
+ * turns a network blip into a non-zero shell exit.
32
+ *
33
+ * - The random-source-style test seam: the run helper accepts an
34
+ * `interactive` flag that the spec sets to false, plus an injected
35
+ * `draft` so unit tests can drive the submit + queue branches
36
+ * without mounting Ink.
37
+ */
38
+ import { existsSync, readFileSync } from 'node:fs';
39
+ import { resolve } from 'node:path';
40
+ import { enqueueFeedback, feedbackQueuePath, flushFeedbackQueue, } from '../../core/feedback/queue.js';
41
+ import { feedbackSubmitUrl, redactSessionContext, submitFeedback, } from '../../core/feedback/submitter.js';
42
+ /**
43
+ * Drive one feedback round. The function is async because of the
44
+ * submit round-trip, but everything else (queue write, redaction) is
45
+ * sync — no surprise concurrency.
46
+ */
47
+ export async function runFeedbackCommand(ctx) {
48
+ if (ctx.draft == null) {
49
+ return { kind: 'cancelled' };
50
+ }
51
+ const envelope = {
52
+ category: ctx.draft.category,
53
+ rating: ctx.draft.rating,
54
+ comment: ctx.draft.comment,
55
+ ts: new Date().toISOString(),
56
+ cliVersion: ctx.cliVersion,
57
+ ...(ctx.tier ? { tier: ctx.tier } : {}),
58
+ };
59
+ if (ctx.draft.includeSessionContext && ctx.sessionContext) {
60
+ const sc = ctx.sessionContext();
61
+ if (sc)
62
+ envelope.sessionContext = sc;
63
+ }
64
+ let result;
65
+ try {
66
+ result = await ctx.submit(envelope);
67
+ }
68
+ catch (err) {
69
+ // Defensive: a thrown submitter (should not happen — the live
70
+ // submitter catches everything) is treated as transient so the
71
+ // envelope lands in the queue.
72
+ const reason = err instanceof Error ? err.message : String(err);
73
+ result = { kind: 'transient', reason };
74
+ }
75
+ if (result.kind === 'ok') {
76
+ return { kind: 'submitted', envelope, httpStatus: result.httpStatus };
77
+ }
78
+ if (result.kind === 'transient') {
79
+ const path = enqueueFeedback(envelope, ctx.cwd);
80
+ return { kind: 'queued', envelope, path, reason: result.reason };
81
+ }
82
+ // permanent — log + drop
83
+ return {
84
+ kind: 'dropped',
85
+ envelope,
86
+ reason: result.reason,
87
+ httpStatus: result.httpStatus,
88
+ };
89
+ }
90
+ /**
91
+ * Render one human-readable toast for the operator. Centralised so the
92
+ * top-level `pugi feedback` shell handler + the in-REPL `/feedback`
93
+ * slash dispatcher agree on the copy.
94
+ */
95
+ export function renderFeedbackToast(result) {
96
+ switch (result.kind) {
97
+ case 'submitted':
98
+ return 'Feedback submitted. Thank you.';
99
+ case 'queued':
100
+ return `Feedback queued locally. Will sync on next online run. (${result.path})`;
101
+ case 'cancelled':
102
+ return 'Feedback cancelled. Nothing was sent.';
103
+ case 'dropped':
104
+ return `Feedback rejected by server (${result.httpStatus}): ${result.reason}. Not queued.`;
105
+ case 'noop':
106
+ return `No feedback collected: ${result.reason}`;
107
+ }
108
+ }
109
+ /**
110
+ * Background queue flush. Invoked silently on session start so any
111
+ * envelopes that landed during an offline run get drained when the
112
+ * operator next has connectivity. The function never throws — it
113
+ * returns the flush stats so the caller can log them at debug level.
114
+ */
115
+ export async function flushFeedbackQueueSilently(cwd, config) {
116
+ // Short-circuit when the queue file does not exist. Avoids a
117
+ // pointless `fs.stat` round-trip on every cold session start.
118
+ if (!existsSync(feedbackQueuePath(cwd))) {
119
+ return { attempted: 0, succeeded: 0, failed: 0 };
120
+ }
121
+ const result = await flushFeedbackQueue(cwd, async (env) => {
122
+ const r = await submitFeedback(env, config);
123
+ if (r.kind === 'ok')
124
+ return true;
125
+ if (r.kind === 'permanent') {
126
+ // Permanent failures are "done" from the queue's POV — they
127
+ // would never resolve on retry. Drop them so the queue does
128
+ // not grow without bound.
129
+ return true;
130
+ }
131
+ return false;
132
+ });
133
+ return {
134
+ attempted: result.attempted,
135
+ succeeded: result.succeeded,
136
+ failed: result.failed,
137
+ };
138
+ }
139
+ /**
140
+ * Re-exports — the spec imports these via the command module so the
141
+ * dependency graph in the test stays single-rooted.
142
+ */
143
+ export { feedbackQueuePath, feedbackSubmitUrl, redactSessionContext, submitFeedback, };
144
+ /**
145
+ * Read the persona conversation log if present. Best-effort: returns
146
+ * an empty list when the file is missing or malformed. The CLI's REPL
147
+ * persists transcripts via the session module at a canonical relative
148
+ * path under `.pugi/sessions/`. The shell-level `pugi feedback` does
149
+ * not have access to a live session, so it tries to pick up the most
150
+ * recent persisted one for the `--with-context` path.
151
+ *
152
+ * Intentionally tolerant — feedback works even with no transcript.
153
+ */
154
+ export function readMostRecentTranscript(cwd, options = {}) {
155
+ // The CLI may persist sessions in several places depending on the
156
+ // surface. We probe the conventional default; the spec drives the
157
+ // function via a fixture file instead of a live REPL.
158
+ const candidate = resolve(cwd, '.pugi', 'sessions', 'latest.jsonl');
159
+ if (!existsSync(candidate))
160
+ return [];
161
+ try {
162
+ const text = readFileSync(candidate, 'utf8');
163
+ const lines = text.split('\n').filter((l) => l.trim().length > 0);
164
+ const turns = [];
165
+ for (const line of lines) {
166
+ try {
167
+ const obj = JSON.parse(line);
168
+ if ((obj.role === 'user' || obj.role === 'assistant' || obj.role === 'system')
169
+ && typeof obj.text === 'string') {
170
+ turns.push({ role: obj.role, text: obj.text });
171
+ }
172
+ }
173
+ catch {
174
+ // skip malformed line
175
+ }
176
+ }
177
+ const cap = options.maxTurns ?? 5;
178
+ return turns.slice(-cap);
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ }
184
+ //# sourceMappingURL=feedback.js.map
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Leak L25 (2026-05-27) — `pugi onboarding` first-run wizard runner.
3
+ *
4
+ * Six-step interactive walk that lands a new operator on a configured
5
+ * Pugi:
6
+ *
7
+ * 1. Welcome + auth status — suggests `pugi login` when no creds
8
+ * 2. Default permission mode — plan / ask / allow / bypass (L6)
9
+ * 3. Output style — default / terse / explanatory /
10
+ * russian-formal / casual (L18)
11
+ * 4. MCP server pointer — link to `pugi mcp add` (L13)
12
+ * 5. Telemetry consent — off / anonymous / community
13
+ * 6. Recap card + marker touch — `pugi doctor` next-step hint
14
+ *
15
+ * Two execution modes:
16
+ *
17
+ * - **Interactive (TTY + no `--non-interactive` flag)** — mount the
18
+ * Ink wizard (`tui/onboarding-wizard.tsx`) and await the
19
+ * operator's verdict step by step. The wizard returns a structured
20
+ * `OnboardingVerdict` describing each chosen value (or "skip"
21
+ * when the operator pressed Enter on the current-value row).
22
+ *
23
+ * - **Non-interactive (CI, pipes, `--non-interactive`, `--json`)** —
24
+ * skip the Ink mount. Print the current values + the wizard tip
25
+ * so a scripted caller sees the structured envelope without an
26
+ * unresponsive raw-mode prompt.
27
+ *
28
+ * Idempotency:
29
+ *
30
+ * - Re-running the wizard reads the CURRENT persisted values (L6
31
+ * `getGlobalDefaultMode`, L18 `resolveOutputStyle`, this module's
32
+ * telemetry-state) and surfaces them as the highlighted row, so
33
+ * pressing Enter on each step is a no-op.
34
+ *
35
+ * - The marker file (`~/.pugi/.onboarded`) is touched on every
36
+ * successful completion. The marker existence is what suppresses
37
+ * the first-run hint on bare `pugi`; resetting it via
38
+ * `pugi onboarding --reset` re-arms the hint without nuking
39
+ * persisted values.
40
+ *
41
+ * Exit codes:
42
+ * 0 — wizard completed (interactive OR non-interactive path)
43
+ * 0 — `--reset` cleared the marker
44
+ * 2 — conflicting flags (e.g. `--reset` + a verdict flag)
45
+ *
46
+ * Exit code 0 for non-interactive is intentional: a CI caller running
47
+ * `pugi onboarding --non-interactive` to dump the current state should
48
+ * not see a non-zero rc; failures (fs EIO, unknown flag) raise to the
49
+ * caller via thrown errors which `runtime/cli.ts` catches.
50
+ */
51
+ import { DEFAULT_PERMISSION_MODE, PERMISSION_MODES, PERMISSION_MODE_GLOSS, getGlobalDefaultMode, setGlobalDefaultMode, } from '../../core/permissions/index.js';
52
+ import { OUTPUT_STYLES, OUTPUT_STYLE_SLUGS, } from '../../core/output-style/presets.js';
53
+ import { resolveOutputStyle, setUserOutputStyle, } from '../../core/output-style/state.js';
54
+ import { clearOnboarded, isOnboarded, markOnboarded, } from '../../core/onboarding/marker.js';
55
+ import { TELEMETRY_CHOICES, readTelemetryChoice, writeTelemetryChoice, } from '../../core/onboarding/telemetry-state.js';
56
+ /**
57
+ * Entry point. Parses argv, resolves the snapshot, optionally drives
58
+ * the wizard, writes verdicts to the L6 / L18 / telemetry tiers,
59
+ * touches the marker, and emits a single structured payload via
60
+ * `writeOutput`.
61
+ */
62
+ export async function runOnboardingCommand(args, ctx) {
63
+ const flags = parseFlags(args);
64
+ if (flags === null) {
65
+ const snapshot = readSnapshot(ctx);
66
+ ctx.writeOutput(buildPayload({
67
+ status: 'invalid_flags',
68
+ before: snapshot,
69
+ after: snapshot,
70
+ hints: [],
71
+ message: invalidFlagsMessage(args),
72
+ }), invalidFlagsMessage(args));
73
+ return 2;
74
+ }
75
+ if (flags.reset) {
76
+ const before = readSnapshot(ctx);
77
+ clearOnboarded(ctx.env ?? process.env);
78
+ const after = readSnapshot(ctx);
79
+ const message = 'Onboarding marker cleared. Run `pugi onboarding` to walk the wizard again.';
80
+ ctx.writeOutput(buildPayload({
81
+ status: 'reset',
82
+ before,
83
+ after,
84
+ hints: [],
85
+ message,
86
+ }), `${message}\n`);
87
+ return 0;
88
+ }
89
+ const before = readSnapshot(ctx);
90
+ if (!ctx.interactive || flags.nonInteractive) {
91
+ const text = renderSnapshotCard(before, {
92
+ heading: 'Pugi onboarding — current configuration',
93
+ footer: 'Re-run `pugi onboarding` from a real terminal to walk the wizard interactively.',
94
+ });
95
+ ctx.writeOutput(buildPayload({
96
+ status: 'non_interactive',
97
+ before,
98
+ after: before,
99
+ hints: buildHints(before),
100
+ message: text,
101
+ }), `${text}\n`);
102
+ return 0;
103
+ }
104
+ const verdict = await invokeWizard(ctx, before);
105
+ if (verdict.cancelled) {
106
+ const text = 'Onboarding cancelled. No changes written.';
107
+ ctx.writeOutput(buildPayload({
108
+ status: 'cancelled',
109
+ before,
110
+ after: before,
111
+ hints: buildHints(before),
112
+ message: text,
113
+ }), `${text}\n`);
114
+ return 0;
115
+ }
116
+ applyVerdict(verdict, ctx);
117
+ markOnboarded(ctx.env ?? process.env);
118
+ const after = readSnapshot(ctx);
119
+ const text = renderSnapshotCard(after, {
120
+ heading: 'Setup complete.',
121
+ footer: 'Run `pugi doctor` to verify your environment.',
122
+ });
123
+ ctx.writeOutput(buildPayload({
124
+ status: 'completed',
125
+ before,
126
+ after,
127
+ hints: buildHints(after),
128
+ message: text,
129
+ }), `${text}\n`);
130
+ return 0;
131
+ }
132
+ /**
133
+ * Pure snapshot reader. Called twice per invocation (before + after)
134
+ * so the payload can surface the diff a scripted caller would see.
135
+ */
136
+ export function readSnapshot(ctx) {
137
+ const permissionMode = getGlobalDefaultMode(ctx.homeDir) ?? DEFAULT_PERMISSION_MODE;
138
+ const styleResolution = resolveOutputStyle({
139
+ workspaceRoot: ctx.workspaceRoot,
140
+ env: ctx.env ?? process.env,
141
+ });
142
+ const telemetry = readTelemetryChoice({ env: ctx.env ?? process.env });
143
+ const previouslyOnboarded = isOnboarded(ctx.env ?? process.env);
144
+ return {
145
+ authPresent: ctx.authPresent,
146
+ permissionMode,
147
+ outputStyle: styleResolution.slug,
148
+ outputStyleSource: styleResolution.source,
149
+ telemetry,
150
+ previouslyOnboarded,
151
+ };
152
+ }
153
+ /**
154
+ * Apply the wizard's verdict. Each step is independent — operator can
155
+ * skip individual steps (verdict.X === null) and only the non-null
156
+ * values write through to disk.
157
+ */
158
+ function applyVerdict(verdict, ctx) {
159
+ if (verdict.permissionMode !== null) {
160
+ setGlobalDefaultMode(verdict.permissionMode, ctx.homeDir);
161
+ }
162
+ if (verdict.outputStyle !== null) {
163
+ setUserOutputStyle(verdict.outputStyle, {
164
+ workspaceRoot: ctx.workspaceRoot,
165
+ env: ctx.env ?? process.env,
166
+ });
167
+ }
168
+ if (verdict.telemetry !== null) {
169
+ writeTelemetryChoice(verdict.telemetry, { env: ctx.env ?? process.env });
170
+ }
171
+ }
172
+ /**
173
+ * Render the recap card surfaced both as the Step 6 exit screen and
174
+ * as the non-interactive snapshot dump.
175
+ */
176
+ export function renderSnapshotCard(snapshot, opts) {
177
+ const styleGloss = OUTPUT_STYLES[snapshot.outputStyle].gloss;
178
+ const modeGloss = PERMISSION_MODE_GLOSS[snapshot.permissionMode];
179
+ const telemetryGloss = TELEMETRY_GLOSS[snapshot.telemetry];
180
+ const authLine = snapshot.authPresent
181
+ ? 'Signed in (run `pugi whoami` for details).'
182
+ : 'Not signed in. Run `pugi login` to authenticate.';
183
+ const lines = [
184
+ opts.heading,
185
+ '',
186
+ ` Auth: ${authLine}`,
187
+ ` Permission mode: ${snapshot.permissionMode} — ${modeGloss}`,
188
+ ` Output style: ${snapshot.outputStyle} (${snapshot.outputStyleSource}) — ${styleGloss}`,
189
+ ` Telemetry: ${snapshot.telemetry} — ${telemetryGloss}`,
190
+ ` MCP servers: add via \`pugi mcp add <name> <command>\``,
191
+ '',
192
+ opts.footer,
193
+ ];
194
+ return lines.join('\n');
195
+ }
196
+ /**
197
+ * Build the hint list — auth pointer + re-onboarding tip. Hints are
198
+ * structured (in the payload) so a JSON caller can dispatch on them.
199
+ */
200
+ function buildHints(snapshot) {
201
+ const hints = [];
202
+ if (!snapshot.authPresent) {
203
+ hints.push('Run `pugi login` to sign in. Pugi works offline-first but auth unlocks the engine + sync.');
204
+ }
205
+ if (snapshot.previouslyOnboarded) {
206
+ hints.push('You have onboarded before — re-running the wizard is safe; Enter on any step keeps the current value.');
207
+ }
208
+ hints.push('Run `pugi doctor` to verify your environment.');
209
+ hints.push('Add MCP servers with `pugi mcp add` or list them via `pugi mcp list`.');
210
+ return Object.freeze(hints);
211
+ }
212
+ /**
213
+ * Drive the wizard. Production callers leave `ctx.promptWizard`
214
+ * undefined, in which case we dynamic-import the Ink wizard so the
215
+ * command module stays free of the React/Ink module graph on the
216
+ * non-interactive path. Specs inject a stub.
217
+ */
218
+ async function invokeWizard(ctx, snapshot) {
219
+ if (ctx.promptWizard) {
220
+ return ctx.promptWizard(snapshot);
221
+ }
222
+ const { renderOnboardingWizard } = await import('../../tui/onboarding-wizard.js');
223
+ return renderOnboardingWizard({ snapshot });
224
+ }
225
+ /**
226
+ * Flag parser. Returns `null` on conflicting flags so the runner can
227
+ * emit `invalid_flags` + rc=2 without a thrown error.
228
+ */
229
+ function parseFlags(args) {
230
+ let reset = false;
231
+ let nonInteractive = false;
232
+ for (const arg of args) {
233
+ if (arg === '--reset') {
234
+ reset = true;
235
+ }
236
+ else if (arg === '--non-interactive' || arg === '--no-tty') {
237
+ nonInteractive = true;
238
+ }
239
+ else {
240
+ return null;
241
+ }
242
+ }
243
+ // `--reset` + `--non-interactive` is redundant but not conflicting — reset wins.
244
+ // No other combinations are illegal at the moment.
245
+ return { reset, nonInteractive };
246
+ }
247
+ function invalidFlagsMessage(args) {
248
+ return [
249
+ `pugi onboarding: unknown flag in \`${args.join(' ')}\`.`,
250
+ 'Usage: pugi onboarding [--reset] [--non-interactive]',
251
+ ].join('\n');
252
+ }
253
+ /**
254
+ * One-line gloss per telemetry choice — surfaced in the recap card.
255
+ * Mirrors the brand voice (no hedging, operator-grade).
256
+ */
257
+ const TELEMETRY_GLOSS = Object.freeze({
258
+ off: 'No telemetry of any kind.',
259
+ anonymous: 'Counts + error categories only; no payloads.',
260
+ community: 'Anonymous + opt-in usage panels.',
261
+ });
262
+ function buildPayload(input) {
263
+ return {
264
+ command: 'onboarding',
265
+ status: input.status,
266
+ snapshotBefore: input.before,
267
+ snapshotAfter: input.after,
268
+ permissionModes: PERMISSION_MODES,
269
+ outputStyles: OUTPUT_STYLE_SLUGS,
270
+ telemetryChoices: TELEMETRY_CHOICES,
271
+ hints: input.hints,
272
+ message: input.message,
273
+ };
274
+ }
275
+ //# sourceMappingURL=onboarding.js.map
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `pugi plan` / `/plan` — Leak L7 quick mode-switch shortcut.
3
+ *
4
+ * `/plan` is the slick UX shortcut for `/permissions plan`: one keystroke
5
+ * (well, five) puts the gate into plan mode + surfaces a banner so the
6
+ * operator knows write/dispatch tools are refused. The model goes off and
7
+ * thinks / researches without side effects until the operator types
8
+ * `/plan --back` (restore previous mode) or explicitly flips with
9
+ * `/permissions <mode>`.
10
+ *
11
+ * The slash and CLI surfaces both go through `runPlanCommand` — same
12
+ * separation as `runPermissionsCommand`. The runtime is I/O free w.r.t.
13
+ * the engine; the optional one-shot dispatch (`/plan <prompt>`) is
14
+ * handled by the CLI dispatcher AFTER this helper sets the workspace
15
+ * mode so the existing `runEngineTask('plan')` path sees plan mode as
16
+ * the workspace state without needing a parallel code path.
17
+ *
18
+ * Verdicts (the helper returns one so the caller can decide what to do
19
+ * after the mode write — print the banner, dispatch the engine, no-op):
20
+ * - `entered` — first `/plan` from a non-plan mode. Print the
21
+ * banner. Caller may then run a one-shot prompt.
22
+ * - `already-in-plan` — `/plan` while already in plan. No-op + show
23
+ * current. No banner reprint.
24
+ * - `reverted` — `/plan --back` popped the snapshot. Print a
25
+ * one-line confirmation; no banner.
26
+ * - `no-previous` — `/plan --back` without a snapshot. Print a
27
+ * clear "nothing to revert" line.
28
+ * - `persisted` — `/plan --persist` wrote the global default
29
+ * AND set workspace state to plan. Banner +
30
+ * persistence-confirmation line.
31
+ *
32
+ * `previousMode` semantics: stashed BEFORE the workspace write on
33
+ * `entered` / `persisted`. Cleared after a successful `reverted` so a
34
+ * second `--back` reports `no-previous` instead of looping back to plan.
35
+ */
36
+ import { PERMISSION_MODES, PERMISSION_MODE_GLOSS, getCurrentMode, getGlobalDefaultMode, getPreviousMode, setCurrentMode, setGlobalDefaultMode, setPreviousMode, } from '../../core/permissions/index.js';
37
+ /**
38
+ * Run the `/plan` flow. Side effects:
39
+ *
40
+ * command.back = true:
41
+ * - If a previousMode snapshot exists → restore it, clear snapshot,
42
+ * return `reverted`.
43
+ * - Otherwise → no writes, return `no-previous`.
44
+ *
45
+ * command.back = false, current mode is plan:
46
+ * - If `--persist`, write global config (no workspace re-write — it
47
+ * is already plan).
48
+ * - Print "already in plan" + the banner-summary line. Return
49
+ * `already-in-plan`.
50
+ *
51
+ * command.back = false, current mode is NOT plan:
52
+ * - Snapshot current mode → previousPermissionMode.
53
+ * - Write workspace mode = plan.
54
+ * - If `--persist`, also write global config.
55
+ * - Print the banner + (if persisted) the persistence line.
56
+ * - Return `entered` or `persisted`.
57
+ *
58
+ * --back + --persist is a no-op for persistence (revert never writes
59
+ * global config) but the revert itself fires.
60
+ */
61
+ export async function runPlanCommand(command, ctx) {
62
+ const current = effectiveMode(ctx);
63
+ if (command.back) {
64
+ const prev = getPreviousMode(ctx.workspaceRoot);
65
+ if (!prev) {
66
+ ctx.writeOutput(`No previous mode to restore. Current: ${current}. Use \`/permissions <mode>\` to switch explicitly.`);
67
+ return { verdict: 'no-previous', mode: current };
68
+ }
69
+ setCurrentMode(ctx.workspaceRoot, prev);
70
+ setPreviousMode(ctx.workspaceRoot, null);
71
+ ctx.writeOutput(`Switched back to '${prev}' mode. ${PERMISSION_MODE_GLOSS[prev]}`);
72
+ return { verdict: 'reverted', mode: prev };
73
+ }
74
+ if (current === 'plan') {
75
+ // Repeat /plan in plan mode is a no-op for the mode write, but
76
+ // --persist still honours the operator's intent to lock plan as
77
+ // the global default for future sessions.
78
+ if (command.persist) {
79
+ setGlobalDefaultMode('plan', ctx.homeDir);
80
+ ctx.writeOutput('Already in plan mode. Persisted plan as the default for future sessions (~/.pugi/config.json).');
81
+ return { verdict: 'persisted', mode: 'plan' };
82
+ }
83
+ ctx.writeOutput(`Already in plan mode. ${PERMISSION_MODE_GLOSS.plan} Switch back with \`/plan --back\` or \`/permissions <mode>\`.`);
84
+ return { verdict: 'already-in-plan', mode: 'plan' };
85
+ }
86
+ // Entering plan mode from a non-plan baseline. Stash the current mode
87
+ // BEFORE the write so /plan --back can pop it. We intentionally use the
88
+ // observed effective mode (workspace || global || default) rather than
89
+ // strictly the workspace value — if the operator's previous mode was
90
+ // sourced from the global config, `--back` should restore that observed
91
+ // state, not silently degrade to default.
92
+ setPreviousMode(ctx.workspaceRoot, current);
93
+ setCurrentMode(ctx.workspaceRoot, 'plan');
94
+ if (command.persist) {
95
+ setGlobalDefaultMode('plan', ctx.homeDir);
96
+ }
97
+ for (const line of renderPlanBanner()) {
98
+ ctx.writeOutput(line);
99
+ }
100
+ if (command.persist) {
101
+ ctx.writeOutput('Persisted plan as the default for future sessions (~/.pugi/config.json).');
102
+ return { verdict: 'persisted', mode: 'plan' };
103
+ }
104
+ return { verdict: 'entered', mode: 'plan' };
105
+ }
106
+ /**
107
+ * Render the plan-mode banner as a sequence of lines. The slash + CLI
108
+ * surfaces both print these line-by-line through their respective
109
+ * `writeOutput` sinks so the Ink REPL conversation pane and the plain
110
+ * stdout pipeline render identically.
111
+ *
112
+ * The box-drawing uses light-line glyphs (U+2500 family) which render in
113
+ * every modern terminal we target (Linux/macOS/Windows Terminal/iTerm/
114
+ * Ghostty/Alacritty). No emoji per brand-voice gate.
115
+ */
116
+ export function renderPlanBanner() {
117
+ return [
118
+ '┌─ Plan mode active ────────────────────────────────────────┐',
119
+ '│ Read-only tools allowed. Write/dispatch tools blocked. │',
120
+ '│ Pugi will think + research without making changes. │',
121
+ '│ Switch back: /plan --back or /permissions <mode> │',
122
+ '└───────────────────────────────────────────────────────────┘',
123
+ ];
124
+ }
125
+ /**
126
+ * Resolve the effective mode at the moment the helper was invoked,
127
+ * mirroring `resolveMode` but without taking a CLI flag (the `/plan`
128
+ * helper is called AFTER the top-level `--mode` flag has been applied
129
+ * to the workspace, so the file state is the source of truth here).
130
+ */
131
+ function effectiveMode(ctx) {
132
+ const workspace = getCurrentMode(ctx.workspaceRoot);
133
+ if (workspace)
134
+ return workspace;
135
+ const global = getGlobalDefaultMode(ctx.homeDir);
136
+ if (global)
137
+ return global;
138
+ // Defensive: PERMISSION_MODES[1] is 'ask' (the canonical default). We
139
+ // index off the canonical list rather than re-import DEFAULT_PERMISSION_MODE
140
+ // here to keep the symbol surface narrow.
141
+ return PERMISSION_MODES[1] ?? 'ask';
142
+ }
143
+ //# sourceMappingURL=plan.js.map