@pellux/goodvibes-tui 0.20.3 → 0.21.0
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/CHANGELOG.md +27 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +3 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +662 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +14 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +12 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +31 -1
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +3 -3
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +91 -12
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +55 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +5 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-data.ts +304 -0
- package/src/input/settings-modal-mutations.ts +154 -0
- package/src/input/settings-modal.ts +182 -220
- package/src/main.ts +57 -57
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +4 -1
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/knowledge-panel.ts +3 -5
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +11 -10
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +77 -8
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +14 -0
- package/src/runtime/bootstrap-core.ts +121 -13
- package/src/runtime/bootstrap.ts +2 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +21 -0
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
1
2
|
import type { ConfigKey, ConfigManager, ConfigSetting, GoodVibesConfig, PersistedFlagState } from '../config/index.ts';
|
|
2
3
|
import { CONFIG_SCHEMA, ConfigError } from '../config/index.ts';
|
|
3
4
|
import type { GoodVibesCliCommand, GoodVibesCliFlags } from './types.ts';
|
|
@@ -62,6 +63,73 @@ function setNestedConfigValue(config: GoodVibesConfig, key: ConfigKey, value: un
|
|
|
62
63
|
(cursor as Record<string, unknown>)[parts[parts.length - 1]!] = value;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Typed accessor for the SDK's private file paths on ConfigManager.
|
|
68
|
+
*
|
|
69
|
+
* SDK coupling: configPath and projectConfigPath are declared `private readonly`
|
|
70
|
+
* in the SDK's ConfigManager (see manager.d.ts — configPath, projectConfigPath). No public accessors exist
|
|
71
|
+
* as of the SDK version pinned in this project. This cast is the narrowest
|
|
72
|
+
* possible workaround. SDK public-accessor request: see HANDOFF-FROM-TUI-SESSION-20260611.md,
|
|
73
|
+
* Item 6 area. If the SDK ever exposes getConfigPath()/getProjectConfigPath(), replace
|
|
74
|
+
* this cast with those calls and remove this comment.
|
|
75
|
+
*
|
|
76
|
+
* Fail-open: if the cast produces undefined (SDK internal rename), the paths are
|
|
77
|
+
* treated as absent and the default is applied safely.
|
|
78
|
+
*/
|
|
79
|
+
function getPersistedPaths(configManager: ConfigManager): { configPath: string | undefined; projectConfigPath: string | undefined } {
|
|
80
|
+
const manager = configManager as unknown as { configPath?: string; projectConfigPath?: string };
|
|
81
|
+
return {
|
|
82
|
+
configPath: typeof manager.configPath === 'string' ? manager.configPath : undefined,
|
|
83
|
+
projectConfigPath: typeof manager.projectConfigPath === 'string' ? manager.projectConfigPath : undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if the given dot-path key is explicitly present anywhere
|
|
89
|
+
* in the provided raw JSON object.
|
|
90
|
+
*/
|
|
91
|
+
function isKeyPresentInRaw(raw: Record<string, unknown>, key: ConfigKey): boolean {
|
|
92
|
+
const parts = key.split('.');
|
|
93
|
+
let cursor: unknown = raw;
|
|
94
|
+
for (const part of parts) {
|
|
95
|
+
if (cursor == null || typeof cursor !== 'object' || !(part in (cursor as object))) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
cursor = (cursor as Record<string, unknown>)[part];
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apply a TUI default to a config key, but ONLY if the user has not explicitly
|
|
105
|
+
* set the key in EITHER their global OR project persisted settings files.
|
|
106
|
+
* Reads the raw settings JSON from disk (bypasses the in-memory merged config)
|
|
107
|
+
* so a user's explicit `false` in either file is never silently overridden at startup.
|
|
108
|
+
*
|
|
109
|
+
* Each settings file is read independently: a parse failure on one file
|
|
110
|
+
* contributes nothing but does NOT prevent the other file from being checked.
|
|
111
|
+
* The default is applied only when the key is absent from every readable file
|
|
112
|
+
* (e.g. new install, both files missing, or both unparseable).
|
|
113
|
+
*/
|
|
114
|
+
export function applyRuntimeConfigDefault(configManager: ConfigManager, key: ConfigKey, defaultValue: unknown): void {
|
|
115
|
+
const { configPath, projectConfigPath } = getPersistedPaths(configManager);
|
|
116
|
+
// Check each settings file independently. A read/parse failure on one path
|
|
117
|
+
// contributes nothing but must not prevent the other path from being checked.
|
|
118
|
+
for (const filePath of [configPath, projectConfigPath]) {
|
|
119
|
+
if (typeof filePath !== 'string' || !existsSync(filePath)) continue;
|
|
120
|
+
try {
|
|
121
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
|
|
122
|
+
if (isKeyPresentInRaw(raw, key)) {
|
|
123
|
+
// Key is explicitly set in this persisted config — respect the user's value.
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Unreadable or malformed JSON — this file contributes nothing; continue to next.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
applyRuntimeConfigValue(configManager, key, defaultValue);
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
export function applyRuntimeConfigValue(configManager: ConfigManager, key: ConfigKey, value: unknown): void {
|
|
66
134
|
const setting = CONFIG_SCHEMA_BY_KEY.get(key);
|
|
67
135
|
if (!setting) {
|
package/src/cli/help.ts
CHANGED
|
@@ -71,12 +71,14 @@ export function renderGoodVibesHelp(binary = 'goodvibes'): string {
|
|
|
71
71
|
' --json Alias for --output-format json',
|
|
72
72
|
' --no-alt-screen Keep output in normal terminal scrollback',
|
|
73
73
|
' --port <port> Port for server/web commands',
|
|
74
|
-
' --hostname <host> Hostname for server/web commands',
|
|
74
|
+
' --hostname <host> Hostname for server/web commands (--host is an alias)',
|
|
75
75
|
' --open Open browser when supported',
|
|
76
76
|
' -r, --resume [id|latest] Resume saved session when supported',
|
|
77
77
|
' -s, --session <id> Use a specific session when supported',
|
|
78
78
|
' --continue Continue the latest session when supported',
|
|
79
|
-
' --fork
|
|
79
|
+
' --fork [id] Fork session (current or specific id) when supported',
|
|
80
|
+
' -y, --yes Auto-confirm prompts (non-interactive)',
|
|
81
|
+
' --non-interactive Disable all interactive prompts (implies --yes)',
|
|
80
82
|
' -h, --help Print help',
|
|
81
83
|
' -v, --version Print version',
|
|
82
84
|
'',
|
|
@@ -9,7 +9,7 @@ import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnecti
|
|
|
9
9
|
import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
10
10
|
import { resolveRuntimeEndpointBinding } from './endpoints.ts';
|
|
11
11
|
import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
|
|
12
|
-
import type { CliCommandRuntime } from './
|
|
12
|
+
import type { CliCommandRuntime } from './types.ts';
|
|
13
13
|
import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, runNonInteractiveAgent, urlHostForBindHost, withRuntimeServices, yesNo } from './management.ts';
|
|
14
14
|
|
|
15
15
|
export async function renderSubscriptions(runtime: CliCommandRuntime): Promise<string> {
|
package/src/cli/management.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibe
|
|
|
21
21
|
import { inspectProviderAuth } from '@/runtime/index.ts';
|
|
22
22
|
import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
23
23
|
import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
24
|
-
import type { GoodVibesCliParseResult } from './types.ts';
|
|
24
|
+
import type { CliCommandRuntime, GoodVibesCliParseResult } from './types.ts';
|
|
25
25
|
import { formatProviderAuthRoute, summarizeProviderAuthRoutes } from './provider-auth-routes.ts';
|
|
26
26
|
import { classifyProviderSetup } from './provider-classification.ts';
|
|
27
27
|
import { resolveRuntimeEndpointBinding } from './endpoints.ts';
|
|
@@ -32,13 +32,6 @@ import { handleBundleCommand } from './bundle-command.ts';
|
|
|
32
32
|
import { buildListenerTestResult, formatListenerTestResult, handleSurfacesCommand } from './surface-command.ts';
|
|
33
33
|
import { buildControlPlaneStatusResult, formatControlPlaneStatus, handleSecrets, handleSessions, handleTasks, renderPairing, renderRemote, renderSubscriptions, renderWeb } from './management-commands.ts';
|
|
34
34
|
|
|
35
|
-
export interface CliCommandRuntime {
|
|
36
|
-
readonly cli: GoodVibesCliParseResult;
|
|
37
|
-
readonly configManager: ConfigManager;
|
|
38
|
-
readonly workingDirectory: string;
|
|
39
|
-
readonly homeDirectory: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
35
|
interface CliCommandResult {
|
|
43
36
|
readonly handled: boolean;
|
|
44
37
|
readonly exitCode: number;
|
package/src/cli/parser.ts
CHANGED
|
@@ -76,9 +76,9 @@ function createDefaultFlags(): GoodVibesCliFlags {
|
|
|
76
76
|
continueLast: false,
|
|
77
77
|
resume: undefined,
|
|
78
78
|
session: undefined,
|
|
79
|
-
fork:
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
fork: undefined,
|
|
80
|
+
yes: false,
|
|
81
|
+
nonInteractive: false,
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -132,9 +132,9 @@ function parsePort(value: string | undefined, optionName: string, errors: string
|
|
|
132
132
|
return port;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
function normalizeOutputFormat(value: string | undefined, errors: string[]): GoodVibesCliOutputFormat {
|
|
135
|
+
function normalizeOutputFormat(value: string | undefined, flagName: string, errors: string[]): GoodVibesCliOutputFormat {
|
|
136
136
|
if (value === 'text' || value === 'json' || value === 'stream-json') return value;
|
|
137
|
-
errors.push(
|
|
137
|
+
errors.push(`${flagName} must be one of: text, json, stream-json.`);
|
|
138
138
|
return 'text';
|
|
139
139
|
}
|
|
140
140
|
|
|
@@ -238,15 +238,17 @@ export function parseGoodVibesCli(
|
|
|
238
238
|
continue;
|
|
239
239
|
}
|
|
240
240
|
if (name === '--fork') {
|
|
241
|
-
|
|
241
|
+
const consumed = getOptionalValue(argv, index, inlineValue);
|
|
242
|
+
index = consumed.nextIndex;
|
|
243
|
+
flags = withFlag(flags, 'fork', consumed.value ?? true);
|
|
242
244
|
continue;
|
|
243
245
|
}
|
|
244
|
-
if (name === '--
|
|
245
|
-
flags = withFlag(flags, '
|
|
246
|
+
if (name === '--yes' || name === '-y') {
|
|
247
|
+
flags = withFlag(flags, 'yes', true);
|
|
246
248
|
continue;
|
|
247
249
|
}
|
|
248
|
-
if (name === '--
|
|
249
|
-
flags = withFlag(flags, '
|
|
250
|
+
if (name === '--non-interactive') {
|
|
251
|
+
flags = withFlag(withFlag(flags, 'nonInteractive', true), 'yes', true);
|
|
250
252
|
continue;
|
|
251
253
|
}
|
|
252
254
|
|
|
@@ -289,16 +291,10 @@ export function parseGoodVibesCli(
|
|
|
289
291
|
if (name === '--output-format' || name === '--output' || name === '-o') {
|
|
290
292
|
const consumed = getValue(argv, index, inlineValue, name, errors);
|
|
291
293
|
index = consumed.nextIndex;
|
|
292
|
-
flags = withFlag(flags, 'outputFormat', normalizeOutputFormat(consumed.value, errors));
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (name === '--config') {
|
|
296
|
-
const consumed = getValue(argv, index, inlineValue, name, errors);
|
|
297
|
-
index = consumed.nextIndex;
|
|
298
|
-
if (consumed.value !== undefined) flags = appendFlagArray(flags, 'configOverrides', consumed.value);
|
|
294
|
+
flags = withFlag(flags, 'outputFormat', normalizeOutputFormat(consumed.value, name, errors));
|
|
299
295
|
continue;
|
|
300
296
|
}
|
|
301
|
-
if (name === '-c') {
|
|
297
|
+
if (name === '--config' || name === '-c') {
|
|
302
298
|
const consumed = getValue(argv, index, inlineValue, name, errors);
|
|
303
299
|
index = consumed.nextIndex;
|
|
304
300
|
if (consumed.value !== undefined) flags = appendFlagArray(flags, 'configOverrides', consumed.value);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
|
-
import type { CliCommandRuntime } from './
|
|
2
|
+
import type { CliCommandRuntime } from './types.ts';
|
|
3
3
|
import { buildCliServicePosture, createPlatformServiceManager, formatCliServicePosture, getServiceStateRoot } from './service-posture.ts';
|
|
4
4
|
import type { CliCommandOutput } from './types.ts';
|
|
5
5
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { enableFeatureFlags, getMissingSurfaceFeatureFlags, getServerSurfaceFeatureFlags } from '../runtime/surface-feature-flags.ts';
|
|
9
9
|
import { resolveRuntimeEndpointBinding } from './endpoints.ts';
|
|
10
10
|
import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
|
|
11
|
-
import type { CliCommandRuntime } from './
|
|
11
|
+
import type { CliCommandRuntime } from './types.ts';
|
|
12
12
|
import {
|
|
13
13
|
applyTargetEndpointFlagsOrDefault,
|
|
14
14
|
enableEndpointLanDefault,
|
package/src/cli/tui-startup.ts
CHANGED
|
@@ -1,32 +1,94 @@
|
|
|
1
1
|
import type { CommandContext, CommandRegistry } from '../input/command-registry.ts';
|
|
2
2
|
import type { InputHandler } from '../input/handler.ts';
|
|
3
|
-
import { readOnboardingCheckMarker } from '../runtime/onboarding/index.ts';
|
|
3
|
+
import { hasResumableWizardProgress, readOnboardingCheckMarker, readWizardProgress } from '../runtime/onboarding/index.ts';
|
|
4
|
+
import { readLastSessionPointer } from '@/runtime/index.ts';
|
|
4
5
|
import type { GoodVibesCliParseResult } from './types.ts';
|
|
5
6
|
|
|
7
|
+
export type TuiStartupShellPaths = Parameters<typeof readOnboardingCheckMarker>[0] & {
|
|
8
|
+
readonly workingDirectory: string;
|
|
9
|
+
readonly homeDirectory: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
6
12
|
export function applyInitialTuiCliState(options: {
|
|
7
13
|
readonly cli: GoodVibesCliParseResult;
|
|
8
14
|
readonly input: InputHandler;
|
|
9
15
|
readonly commandRegistry: CommandRegistry;
|
|
10
16
|
readonly commandContext: CommandContext;
|
|
11
|
-
readonly shellPaths:
|
|
17
|
+
readonly shellPaths: TuiStartupShellPaths;
|
|
12
18
|
readonly render: () => void;
|
|
13
|
-
}): void {
|
|
19
|
+
}): Promise<void> | undefined {
|
|
14
20
|
const { cli, input, commandRegistry, commandContext, shellPaths, render } = options;
|
|
15
21
|
const globalOnboardingMarker = readOnboardingCheckMarker(shellPaths, 'user');
|
|
22
|
+
|
|
23
|
+
// Seeded prompt is always applied synchronously, regardless of session branch.
|
|
24
|
+
const seededPrompt = cli.flags.prompt ?? (cli.rawCommand === undefined && cli.positionals.length > 0 ? cli.positionals.join(' ') : undefined);
|
|
25
|
+
if (seededPrompt) {
|
|
26
|
+
input.prompt = seededPrompt;
|
|
27
|
+
input.cursorPos = seededPrompt.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
if (cli.command === 'onboarding') {
|
|
17
31
|
input.openOnboardingWizard({ mode: 'edit', reset: true });
|
|
18
32
|
} else if (cli.command === 'sessions' && cli.commandArgs[0] === 'resume') {
|
|
19
33
|
const target = cli.commandArgs.slice(1).join(' ').trim();
|
|
20
34
|
if (target) {
|
|
21
|
-
|
|
35
|
+
return commandRegistry.execute('session', ['resume', target], commandContext).then(() => render());
|
|
36
|
+
}
|
|
37
|
+
} else if (cli.flags.continueLast) {
|
|
38
|
+
// --continue: resume the last session tracked by the pointer file
|
|
39
|
+
const lastId = readLastSessionPointer({
|
|
40
|
+
workingDirectory: shellPaths.workingDirectory,
|
|
41
|
+
homeDirectory: shellPaths.homeDirectory,
|
|
42
|
+
surfaceRoot: 'tui',
|
|
43
|
+
});
|
|
44
|
+
if (lastId) {
|
|
45
|
+
return commandRegistry.execute('session', ['resume', lastId], commandContext).then(() => render());
|
|
46
|
+
}
|
|
47
|
+
} else if (cli.flags.resume !== undefined) {
|
|
48
|
+
// --resume [id]: explicit id dispatches directly; bare form (sentinel 'latest') resolves via pointer
|
|
49
|
+
if (cli.flags.resume !== 'latest') {
|
|
50
|
+
return commandRegistry.execute('session', ['resume', cli.flags.resume], commandContext).then(() => render());
|
|
51
|
+
} else {
|
|
52
|
+
const lastId = readLastSessionPointer({
|
|
53
|
+
workingDirectory: shellPaths.workingDirectory,
|
|
54
|
+
homeDirectory: shellPaths.homeDirectory,
|
|
55
|
+
surfaceRoot: 'tui',
|
|
56
|
+
});
|
|
57
|
+
if (lastId) {
|
|
58
|
+
return commandRegistry.execute('session', ['resume', lastId], commandContext).then(() => render());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else if (cli.flags.fork !== undefined) {
|
|
62
|
+
// --fork [id]: fork specific session (true = bare fork-current; string = explicit id to resume then fork)
|
|
63
|
+
if (cli.flags.fork === true) {
|
|
64
|
+
// Bare --fork: fork the current session without a prior resume
|
|
65
|
+
return commandRegistry.execute('session', ['fork'], commandContext).then(() => render());
|
|
66
|
+
} else {
|
|
67
|
+
// Explicit id: resume the named session first, then fork
|
|
68
|
+
return commandRegistry.execute('session', ['resume', cli.flags.fork], commandContext)
|
|
69
|
+
.then(() => commandRegistry.execute('session', ['fork'], commandContext))
|
|
70
|
+
.then(() => render());
|
|
22
71
|
}
|
|
23
72
|
} else if (!globalOnboardingMarker.exists) {
|
|
24
73
|
input.openOnboardingWizard({ mode: 'new', reset: true });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
74
|
+
} else {
|
|
75
|
+
// User has completed onboarding before but left a wizard session in progress.
|
|
76
|
+
// Reopen the wizard at the last saved step so they can continue or dismiss.
|
|
77
|
+
const progressState = readWizardProgress(shellPaths);
|
|
78
|
+
if (hasResumableWizardProgress(shellPaths, { state: progressState })) {
|
|
79
|
+
const { payload } = progressState;
|
|
80
|
+
if (payload !== null) {
|
|
81
|
+
input.openOnboardingWizard({
|
|
82
|
+
mode: payload.mode,
|
|
83
|
+
reset: true,
|
|
84
|
+
preload: (wizard) => {
|
|
85
|
+
wizard.setStep(payload.stepIndex);
|
|
86
|
+
for (const [fieldId, value] of payload.toggleState) wizard.toggleState.set(fieldId, value);
|
|
87
|
+
for (const [fieldId, value] of payload.radioState) wizard.radioState.set(fieldId, value);
|
|
88
|
+
for (const [fieldId, value] of payload.textState) wizard.textState.set(fieldId, value);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
31
93
|
}
|
|
32
94
|
}
|
package/src/cli/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ConfigManager } from '../config/index.ts';
|
|
2
|
+
|
|
1
3
|
export type GoodVibesCliCommand =
|
|
2
4
|
| 'tui'
|
|
3
5
|
| 'run'
|
|
@@ -53,9 +55,9 @@ export interface GoodVibesCliFlags {
|
|
|
53
55
|
readonly continueLast: boolean;
|
|
54
56
|
readonly resume: string | undefined;
|
|
55
57
|
readonly session: string | undefined;
|
|
56
|
-
readonly fork:
|
|
57
|
-
readonly
|
|
58
|
-
readonly
|
|
58
|
+
readonly fork: string | true | undefined;
|
|
59
|
+
readonly yes: boolean;
|
|
60
|
+
readonly nonInteractive: boolean;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
export interface GoodVibesCliParseResult {
|
|
@@ -67,3 +69,10 @@ export interface GoodVibesCliParseResult {
|
|
|
67
69
|
readonly flags: GoodVibesCliFlags;
|
|
68
70
|
readonly errors: readonly string[];
|
|
69
71
|
}
|
|
72
|
+
|
|
73
|
+
export interface CliCommandRuntime {
|
|
74
|
+
readonly cli: GoodVibesCliParseResult;
|
|
75
|
+
readonly configManager: ConfigManager;
|
|
76
|
+
readonly workingDirectory: string;
|
|
77
|
+
readonly homeDirectory: string;
|
|
78
|
+
}
|
package/src/cli-flags.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Compatibility wrapper for older imports. New CLI code lives in src/cli.
|
|
2
2
|
export type { GoodVibesCliFlags as CliFlags } from './cli/types.ts';
|
|
3
3
|
export {
|
|
4
|
+
applyRuntimeConfigDefault,
|
|
4
5
|
applyRuntimeConfigOverrides,
|
|
5
6
|
applyRuntimeConfigValue,
|
|
6
7
|
applyRuntimeCommandEndpointFlagOverrides,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface AtomicWriteOptions {
|
|
6
|
+
/**
|
|
7
|
+
* File permission mode for the written file. Defaults to 0o600 (owner read/write only).
|
|
8
|
+
*/
|
|
9
|
+
readonly mode?: number;
|
|
10
|
+
/**
|
|
11
|
+
* When true, creates parent directories if they do not exist. Defaults to false.
|
|
12
|
+
*/
|
|
13
|
+
readonly mkdirp?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Writes `data` to `path` atomically: writes to a sibling temp file, fsyncs
|
|
18
|
+
* it, then renames it into place. On POSIX, rename(2) is atomic so readers
|
|
19
|
+
* always see either the old file or the new file — never a partial write.
|
|
20
|
+
*
|
|
21
|
+
* If the write or fsync fails, the temp file is cleaned up before rethrowing.
|
|
22
|
+
*
|
|
23
|
+
* @param path Destination file path.
|
|
24
|
+
* @param data UTF-8 string content to write.
|
|
25
|
+
* @param opts Optional mode and mkdirp flags.
|
|
26
|
+
*/
|
|
27
|
+
export function atomicWriteFileSync(
|
|
28
|
+
path: string,
|
|
29
|
+
data: string,
|
|
30
|
+
opts: AtomicWriteOptions = {},
|
|
31
|
+
): void {
|
|
32
|
+
const mode = opts.mode ?? 0o600;
|
|
33
|
+
|
|
34
|
+
if (opts.mkdirp) {
|
|
35
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(tmp, data, { mode });
|
|
42
|
+
|
|
43
|
+
// fsync the temp file to flush OS write buffers before rename.
|
|
44
|
+
const fd = openSync(tmp, 'r+');
|
|
45
|
+
try {
|
|
46
|
+
fsyncSync(fd);
|
|
47
|
+
} finally {
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
renameSync(tmp, path);
|
|
52
|
+
|
|
53
|
+
// fsync the parent directory to ensure the renamed directory entry is
|
|
54
|
+
// flushed. Without this, a hard crash immediately after rename could lose
|
|
55
|
+
// the entry on some filesystems (ext3/ext4 with data=ordered, etc.).
|
|
56
|
+
const dirFd = openSync(dirname(path), 'r');
|
|
57
|
+
try {
|
|
58
|
+
fsyncSync(dirFd);
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(dirFd);
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
try {
|
|
64
|
+
unlinkSync(tmp);
|
|
65
|
+
} catch {
|
|
66
|
+
// Best-effort cleanup — original error takes priority.
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A migration function that transforms data from version N to N+1.
|
|
5
|
+
* Receives the raw parsed object and must return the upgraded object.
|
|
6
|
+
*/
|
|
7
|
+
export type VersionMigration = (data: Record<string, unknown>) => Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export interface ReadVersionedOptions {
|
|
10
|
+
/**
|
|
11
|
+
* The version number this reader expects. When the file version equals
|
|
12
|
+
* `currentVersion`, no migrations are run. When it is lower, migrations
|
|
13
|
+
* are applied stepwise. When it is higher or unrecognised, `onUnknown`
|
|
14
|
+
* behaviour fires.
|
|
15
|
+
*/
|
|
16
|
+
readonly currentVersion: number;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optional stepwise migrations indexed by the FROM version.
|
|
20
|
+
* `migrations[1]` upgrades version-1 data to version-2 data.
|
|
21
|
+
* Applied in ascending order until `currentVersion` is reached.
|
|
22
|
+
*/
|
|
23
|
+
readonly migrations?: Readonly<Record<number, VersionMigration>>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to do when the file version is unrecognised (higher than
|
|
27
|
+
* `currentVersion` or missing/non-numeric).
|
|
28
|
+
*
|
|
29
|
+
* `'quarantine'` — rename the file to `<path>.unrecognized` and return null.
|
|
30
|
+
*/
|
|
31
|
+
readonly onUnknown: 'quarantine';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Migration-aware, quarantine-on-failure versioned file reader.
|
|
36
|
+
*
|
|
37
|
+
* Parse flow:
|
|
38
|
+
* 1. If the file does not exist → return null.
|
|
39
|
+
* 2. If JSON is corrupt → quarantine to `<path>.unrecognized`, return null.
|
|
40
|
+
* 3. If the version field is missing or higher than currentVersion →
|
|
41
|
+
* quarantine, return null.
|
|
42
|
+
* 4. If the version is lower than currentVersion → apply stepwise migrations.
|
|
43
|
+
* If no migration exists for a version gap, or a migration throws,
|
|
44
|
+
* quarantine and return null.
|
|
45
|
+
* 5. Return the (possibly migrated) object. Callers are responsible for
|
|
46
|
+
* narrowing the returned value — this helper handles versioning and
|
|
47
|
+
* corruption only, not schema validation.
|
|
48
|
+
*/
|
|
49
|
+
export function readVersioned<T extends { version: number }>(
|
|
50
|
+
path: string,
|
|
51
|
+
options: ReadVersionedOptions,
|
|
52
|
+
): T | null {
|
|
53
|
+
if (!existsSync(path)) return null;
|
|
54
|
+
|
|
55
|
+
let raw: unknown;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
58
|
+
} catch {
|
|
59
|
+
quarantine(path);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!isPlainObject(raw)) {
|
|
64
|
+
quarantine(path);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fileVersion = raw['version'];
|
|
69
|
+
if (typeof fileVersion !== 'number' || !Number.isFinite(fileVersion)) {
|
|
70
|
+
quarantine(path);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (fileVersion > options.currentVersion) {
|
|
75
|
+
// Produced by a newer process — quarantine rather than corrupt.
|
|
76
|
+
quarantine(path);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let data: Record<string, unknown> = raw;
|
|
81
|
+
|
|
82
|
+
if (fileVersion < options.currentVersion) {
|
|
83
|
+
const migrations = options.migrations ?? {};
|
|
84
|
+
for (let v = fileVersion; v < options.currentVersion; v++) {
|
|
85
|
+
const migrate = migrations[v];
|
|
86
|
+
if (!migrate) {
|
|
87
|
+
// No migration path for this version gap — quarantine.
|
|
88
|
+
quarantine(path);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
data = migrate(data);
|
|
93
|
+
} catch {
|
|
94
|
+
quarantine(path);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return data as T;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
106
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function quarantine(path: string): void {
|
|
110
|
+
try {
|
|
111
|
+
renameSync(path, `${path}.unrecognized`);
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort — if rename fails (e.g. race), proceed silently.
|
|
114
|
+
}
|
|
115
|
+
}
|