@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,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
|