@aragon-agent/cli 0.5.0 → 0.5.2

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 (102) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +254 -7
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +247 -23
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +53 -1
  23. package/dist/config/load.d.ts +10 -0
  24. package/dist/config/load.js +82 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +81 -0
  30. package/dist/config/schema.js +85 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/input/mouse-events.d.ts +61 -0
  34. package/dist/input/mouse-events.js +153 -0
  35. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  36. package/dist/input/stdin-mouse-filter.js +153 -0
  37. package/dist/logging/cli-commands.d.ts +25 -0
  38. package/dist/logging/cli-commands.js +221 -0
  39. package/dist/logging/file-sink.d.ts +114 -0
  40. package/dist/logging/file-sink.js +326 -0
  41. package/dist/logging/install.d.ts +163 -0
  42. package/dist/logging/install.js +302 -0
  43. package/dist/logging/levels.d.ts +40 -0
  44. package/dist/logging/levels.js +59 -0
  45. package/dist/logging/logger.d.ts +110 -0
  46. package/dist/logging/logger.js +237 -0
  47. package/dist/logging/redact.d.ts +28 -0
  48. package/dist/logging/redact.js +90 -0
  49. package/dist/logging/secret-registry.d.ts +30 -0
  50. package/dist/logging/secret-registry.js +55 -0
  51. package/dist/skills/paths.d.ts +2 -4
  52. package/dist/skills/paths.js +7 -9
  53. package/dist/tools/human-input.d.ts +179 -0
  54. package/dist/tools/human-input.js +227 -0
  55. package/dist/tools/index.d.ts +35 -3
  56. package/dist/tools/index.js +96 -9
  57. package/dist/tools/plan-tools.d.ts +64 -0
  58. package/dist/tools/plan-tools.js +198 -0
  59. package/dist/ui/App.d.ts +12 -1
  60. package/dist/ui/App.js +223 -17
  61. package/dist/ui/Composer.d.ts +9 -1
  62. package/dist/ui/Composer.js +26 -7
  63. package/dist/ui/ModeChip.d.ts +29 -0
  64. package/dist/ui/ModeChip.js +15 -0
  65. package/dist/ui/PromptInput.d.ts +22 -1
  66. package/dist/ui/PromptInput.js +49 -2
  67. package/dist/ui/StatusBar.d.ts +15 -0
  68. package/dist/ui/StatusBar.js +10 -2
  69. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  70. package/dist/ui/entries/ToolPreview.js +64 -0
  71. package/dist/ui/glyphs.d.ts +21 -0
  72. package/dist/ui/glyphs.js +12 -0
  73. package/dist/ui/layout/AppShell.d.ts +10 -1
  74. package/dist/ui/layout/AppShell.js +38 -3
  75. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  76. package/dist/ui/layout/ScrollIndicator.js +15 -0
  77. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  78. package/dist/ui/layout/ScrollViewport.js +24 -4
  79. package/dist/ui/layout/regions.d.ts +55 -0
  80. package/dist/ui/layout/regions.js +52 -0
  81. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  82. package/dist/ui/layout/scroll-indicator.js +35 -0
  83. package/dist/ui/layout/scroll.d.ts +9 -0
  84. package/dist/ui/layout/scroll.js +23 -0
  85. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  86. package/dist/ui/layout/wrap-rows.js +74 -0
  87. package/dist/ui/overlays/HelpOverlay.js +15 -0
  88. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  89. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  90. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  91. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  93. package/dist/ui/overlays/SettingsScreen.js +8 -1
  94. package/dist/ui/screen.d.ts +14 -1
  95. package/dist/ui/screen.js +37 -3
  96. package/dist/ui/use-startup-notices.d.ts +25 -0
  97. package/dist/ui/use-startup-notices.js +64 -0
  98. package/dist/ui/use-wheel-routing.d.ts +68 -0
  99. package/dist/ui/use-wheel-routing.js +139 -0
  100. package/package.json +3 -2
  101. package/runtime/insecure-tls-warning.cjs +62 -0
  102. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -1,16 +1,147 @@
1
1
  /**
2
- * The `env-paths` application name, in one place.
2
+ * The user-state root, in one place.
3
3
  *
4
4
  * `config/store.ts` and `skills/paths.ts` each used to carry their own copy of
5
- * this string literal. Two copies of a name is exactly the shape that lets a
5
+ * the path arithmetic. Two copies of a name is exactly the shape that lets a
6
6
  * rename land in one of them and not the other, and the failure is silent: the
7
7
  * config file and the skills directory simply stop agreeing about where the
8
- * user's data lives.
8
+ * user's data lives. Everything below derives from `homeRoot`.
9
+ *
10
+ * LAYOUT (aragon-home-config-and-logging §4.1):
11
+ *
12
+ * ~/.aragon-agent/
13
+ * ├── config.json persisted config (0600 on POSIX)
14
+ * ├── config.json.bak `aragon config edit` pre-edit backup
15
+ * ├── logs/ JSONL logs (0700 on POSIX)
16
+ * ├── sessions/ /save · /resume
17
+ * ├── skills/ `aragon skills install` target
18
+ * └── skill-usage.json
19
+ *
20
+ * The same path on all three platforms, deliberately. Claude Code, git and npm
21
+ * all put their dot-directory in the user's home; one path means documentation,
22
+ * support scripts and community answers have exactly one thing to say. A user
23
+ * who wants XDG has `ARAGON_HOME=$XDG_CONFIG_HOME/aragon-agent`.
24
+ *
25
+ * ══════════════════════════════════════════════════════════════════════════
26
+ * TEST-ISOLATION CONTRACT — read before editing anything here or in the tests.
27
+ *
28
+ * `getHomeRoot()` returns the WHOLE user root, not just a config directory.
29
+ * Under the old `env-paths` layout the config directory held one file; here it
30
+ * holds the API key, every saved session and every installed skill. That makes
31
+ * a careless `rmSync(<root>, { recursive: true })` in a test an unrecoverable
32
+ * data loss on the developer's own machine, at a moment when nobody expects the
33
+ * filesystem to be touched at all. Four rules keep that impossible:
34
+ *
35
+ * 1. There is NO `getConfigDir()`. A function whose name says "config dir"
36
+ * must never hand back the user root. Code that needs the root calls
37
+ * `getHomeRoot()`, where the size of what you are holding is obvious.
38
+ * 2. `config.test.ts::clearEnv()` must EXEMPT `ARAGON_HOME` — deleting it
39
+ * would send any later, non-module-level resolution at the developer's
40
+ * real home.
41
+ * 3. Tests never recursively delete a path a function returned. Delete a
42
+ * single file (`rmSync(getConfigPath(), { force: true })`), or delete a
43
+ * directory the test itself built under `os.tmpdir()`.
44
+ * 4. The VITEST branch below. In a test process with no explicit
45
+ * `ARAGON_HOME`, the root is redirected into `os.tmpdir()`. Redirecting
46
+ * rather than throwing is deliberate: throwing would turn every unrelated
47
+ * test that merely imports this module red, and that cost is what tempts
48
+ * someone to delete the guard.
49
+ * ══════════════════════════════════════════════════════════════════════════
9
50
  */
51
+ import { existsSync, statSync } from 'node:fs';
52
+ import { homedir, tmpdir } from 'node:os';
53
+ import { join, resolve } from 'node:path';
54
+ import process from 'node:process';
10
55
  import envPaths from 'env-paths';
11
56
  export const APP_NAME = 'aragon-agent';
12
- export const appPaths = envPaths(APP_NAME);
13
- // The pre-rename name lives in `migrate-legacy-state.ts`, not here. It has
14
- // exactly one consumer, it is transitional, and keeping it out of this module
15
- // means retiring the migration some day is a single-file delete rather than an
16
- // edit that has to remember to come back through here.
57
+ export const HOME_DIR_NAME = '.aragon-agent';
58
+ /**
59
+ * The pre-0.6.0 `env-paths` roots.
60
+ *
61
+ * ONLY the two migration modules may read this. Any other reference is a bug:
62
+ * it would point new code at the directory the migration exists to empty.
63
+ */
64
+ export const legacyEnvPaths = envPaths(APP_NAME);
65
+ function isDirectory(path) {
66
+ try {
67
+ return statSync(path).isDirectory();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * Where to land when `ARAGON_HOME` is absent or unusable.
75
+ *
76
+ * Inside vitest this is a per-pid temp root, never the developer's real home —
77
+ * rule 4 of the contract above, and it has to apply to the FALLBACK too, or a
78
+ * test that sets `ARAGON_HOME` to something bogus quietly gets the real one.
79
+ */
80
+ function fallbackRoot() {
81
+ if (process.env.VITEST)
82
+ return join(tmpdir(), `aragon-agent-vitest-${process.pid}`);
83
+ return join(homedir(), HOME_DIR_NAME);
84
+ }
85
+ function resolveHomeRoot() {
86
+ const raw = process.env.ARAGON_HOME?.trim();
87
+ if (raw && raw.length > 0) {
88
+ const candidate = resolve(raw);
89
+ if (existsSync(candidate) && !isDirectory(candidate)) {
90
+ return {
91
+ root: fallbackRoot(),
92
+ overridden: false,
93
+ warning: `ARAGON_HOME=${candidate} is not a directory; using ${fallbackRoot()}`,
94
+ };
95
+ }
96
+ return { root: candidate, overridden: true };
97
+ }
98
+ return { root: fallbackRoot(), overridden: false };
99
+ }
100
+ /**
101
+ * Resolved ONCE at module load, matching what `envPaths()` did here before.
102
+ *
103
+ * The existing tests set their environment and then `await import(...)`; keeping
104
+ * the same model means test isolation stays "set the env first", not a new mock
105
+ * framework. It also means `isHomeOverridden()` must report the value captured
106
+ * here rather than re-reading `process.env` — `config.test.ts::clearEnv()` wipes
107
+ * `ARAGON_*` before every test, and a live read would answer `false` for a run
108
+ * that really is overridden.
109
+ */
110
+ const resolution = resolveHomeRoot();
111
+ /** `~/.aragon-agent` (or `$ARAGON_HOME`). The root of everything below. */
112
+ export function getHomeRoot() {
113
+ return resolution.root;
114
+ }
115
+ export function getConfigPath() {
116
+ return join(resolution.root, 'config.json');
117
+ }
118
+ /** Written by `aragon config edit` before spawning the editor. */
119
+ export function getConfigBackupPath() {
120
+ return join(resolution.root, 'config.json.bak');
121
+ }
122
+ export function getLogsDir() {
123
+ return join(resolution.root, 'logs');
124
+ }
125
+ export function getSessionsDir() {
126
+ return join(resolution.root, 'sessions');
127
+ }
128
+ /** `<home>` — parent of `skills/`, and where sidecar state such as usage lives. */
129
+ export function getUserDataDir() {
130
+ return resolution.root;
131
+ }
132
+ export function getUserSkillsDir() {
133
+ return join(resolution.root, 'skills');
134
+ }
135
+ /** Whether `ARAGON_HOME` was honoured. Captured at load — see `resolution`. */
136
+ export function isHomeOverridden() {
137
+ return resolution.overridden;
138
+ }
139
+ /**
140
+ * Why an `ARAGON_HOME` override was ignored, if it was.
141
+ *
142
+ * Path resolution happens before the logger exists, so the reason is parked
143
+ * here and written out by `installLogging()` once there is somewhere to put it.
144
+ */
145
+ export function getHomeResolutionWarning() {
146
+ return resolution.warning;
147
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `aragon config get | list | edit | home`, plus the `log.*` branch of
3
+ * `config set`.
4
+ *
5
+ * These live here rather than in `cli.tsx` because `runConfigSet` is already a
6
+ * 21-case switch — well past the complexity ceiling this package writes down —
7
+ * and `cli.tsx` has two other designs queued up against it. `cli.tsx` keeps the
8
+ * wiring; the behaviour is here.
9
+ */
10
+ import { type PersistedConfig } from './schema.js';
11
+ /** Config keys under `log.` that `aragon config set` accepts. */
12
+ export declare const LOG_CONFIG_SET_KEYS: readonly ["log.level", "log.toFile", "log.dir", "log.maxFileBytes", "log.maxFiles", "log.redactSecrets", "log.previewChars"];
13
+ /**
14
+ * Translate a `log.*` dotted key into a patch, or `null` for anything else.
15
+ *
16
+ * The patch is a PARTIAL `log` section, which is safe only because
17
+ * `updatePersistedConfig` deep-merges that section — with a shallow merge each
18
+ * of these would silently reset every other log setting, `redactSecrets`
19
+ * included.
20
+ */
21
+ export declare function applyLogConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
22
+ /**
23
+ * `log.redactSecrets false` is the one setting that lets credentials reach the
24
+ * disk. It is command-line-only (there is no settings-screen entry) and it says
25
+ * so out loud, because a user who turns it off and forgets is the user who
26
+ * later pastes a log into an issue.
27
+ */
28
+ export declare function warnIfRedactionDisabled(key: string, value: string): void;
29
+ export declare function runConfigGet(key: string): void;
30
+ export declare function runConfigList(opts?: {
31
+ json?: boolean;
32
+ }): void;
33
+ /**
34
+ * Open `config.json` in the user's editor.
35
+ *
36
+ * Backs the file up FIRST and re-parses afterwards. Hand-editing is a supported
37
+ * workflow here, and a stray comma otherwise resets every setting the next time
38
+ * the CLI starts, with nothing connecting the two events. Telling the user
39
+ * immediately — and naming the backup — turns a confusing regression into a
40
+ * one-command recovery.
41
+ */
42
+ export declare function runConfigEdit(): Promise<void>;
43
+ export declare function runConfigHome(): void;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `aragon config get | list | edit | home`, plus the `log.*` branch of
3
+ * `config set`.
4
+ *
5
+ * These live here rather than in `cli.tsx` because `runConfigSet` is already a
6
+ * 21-case switch — well past the complexity ceiling this package writes down —
7
+ * and `cli.tsx` has two other designs queued up against it. `cli.tsx` keeps the
8
+ * wiring; the behaviour is here.
9
+ */
10
+ import { spawnSync } from 'node:child_process';
11
+ import { copyFileSync, existsSync, readFileSync } from 'node:fs';
12
+ import process from 'node:process';
13
+ import { getConfigBackupPath, getConfigPath, getHomeRoot, getLogsDir, isHomeOverridden, } from './app-paths.js';
14
+ import { getHomeMigrationBreadcrumbPath } from './migrate-home.js';
15
+ import { loadPersistedConfig, updatePersistedConfig } from './store.js';
16
+ import { coercePositiveInt, maskSecret, } from './schema.js';
17
+ import { clampLogLevel } from '../logging/levels.js';
18
+ /** Config keys under `log.` that `aragon config set` accepts. */
19
+ export const LOG_CONFIG_SET_KEYS = [
20
+ 'log.level',
21
+ 'log.toFile',
22
+ 'log.dir',
23
+ 'log.maxFileBytes',
24
+ 'log.maxFiles',
25
+ 'log.redactSecrets',
26
+ 'log.previewChars',
27
+ ];
28
+ function isTrue(value) {
29
+ return value === 'true' || value === '1';
30
+ }
31
+ /**
32
+ * Translate a `log.*` dotted key into a patch, or `null` for anything else.
33
+ *
34
+ * The patch is a PARTIAL `log` section, which is safe only because
35
+ * `updatePersistedConfig` deep-merges that section — with a shallow merge each
36
+ * of these would silently reset every other log setting, `redactSecrets`
37
+ * included.
38
+ */
39
+ export function applyLogConfigSet(key, value) {
40
+ const patch = (log) => ({ log });
41
+ switch (key) {
42
+ case 'log.level':
43
+ return patch({ level: clampLogLevel(value, 'info') });
44
+ case 'log.toFile':
45
+ return patch({ toFile: isTrue(value) });
46
+ case 'log.dir':
47
+ return patch({ dir: value.trim() });
48
+ case 'log.maxFileBytes':
49
+ return patch({ maxFileBytes: coercePositiveInt(value, 5 * 1024 * 1024) });
50
+ case 'log.maxFiles':
51
+ return patch({ maxFiles: coercePositiveInt(value, 10) });
52
+ case 'log.redactSecrets':
53
+ return patch({ redactSecrets: isTrue(value) });
54
+ case 'log.previewChars':
55
+ return patch({ previewChars: Number.parseInt(value, 10) });
56
+ default:
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * `log.redactSecrets false` is the one setting that lets credentials reach the
62
+ * disk. It is command-line-only (there is no settings-screen entry) and it says
63
+ * so out loud, because a user who turns it off and forgets is the user who
64
+ * later pastes a log into an issue.
65
+ */
66
+ export function warnIfRedactionDisabled(key, value) {
67
+ if (key !== 'log.redactSecrets' || isTrue(value))
68
+ return;
69
+ process.stdout.write('WARNING: secret redaction is OFF. API keys may now be written to the log files ' +
70
+ `in ${getLogsDir()}. Re-enable with: aragon config set log.redactSecrets true\n`);
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // get / list
74
+ // ---------------------------------------------------------------------------
75
+ /** Every secret is masked wherever it is printed, `--json` included (R-12). */
76
+ function maskConfig(config) {
77
+ const apiKeys = {};
78
+ for (const [provider, key] of Object.entries(config.apiKeys)) {
79
+ apiKeys[provider] = maskSecret(key);
80
+ }
81
+ return { ...config, apiKeys };
82
+ }
83
+ /** Resolve a flat or one-level-dotted key against the persisted config. */
84
+ function readKey(config, key) {
85
+ const [head, tail] = key.split('.', 2);
86
+ const top = config[head];
87
+ if (tail === undefined)
88
+ return top;
89
+ if (!top || typeof top !== 'object')
90
+ return undefined;
91
+ return top[tail];
92
+ }
93
+ export function runConfigGet(key) {
94
+ const masked = maskConfig(loadPersistedConfig());
95
+ const value = readKey(masked, key);
96
+ if (value === undefined) {
97
+ process.stderr.write(`Unknown config key "${key}".\n`);
98
+ process.exitCode = 2;
99
+ return;
100
+ }
101
+ process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value)}\n`);
102
+ }
103
+ export function runConfigList(opts = {}) {
104
+ const masked = maskConfig(loadPersistedConfig());
105
+ if (opts.json) {
106
+ process.stdout.write(`${JSON.stringify(masked, null, 2)}\n`);
107
+ return;
108
+ }
109
+ for (const [key, value] of Object.entries(masked)) {
110
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
111
+ for (const [sub, subValue] of Object.entries(value)) {
112
+ process.stdout.write(`${key}.${sub} = ${JSON.stringify(subValue)}\n`);
113
+ }
114
+ continue;
115
+ }
116
+ process.stdout.write(`${key} = ${JSON.stringify(value)}\n`);
117
+ }
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // edit / home
121
+ // ---------------------------------------------------------------------------
122
+ function pickEditor() {
123
+ const configured = process.env.VISUAL?.trim() || process.env.EDITOR?.trim();
124
+ if (configured)
125
+ return configured;
126
+ return process.platform === 'win32' ? 'notepad' : 'vi';
127
+ }
128
+ /**
129
+ * Open `config.json` in the user's editor.
130
+ *
131
+ * Backs the file up FIRST and re-parses afterwards. Hand-editing is a supported
132
+ * workflow here, and a stray comma otherwise resets every setting the next time
133
+ * the CLI starts, with nothing connecting the two events. Telling the user
134
+ * immediately — and naming the backup — turns a confusing regression into a
135
+ * one-command recovery.
136
+ */
137
+ export async function runConfigEdit() {
138
+ const file = getConfigPath();
139
+ if (!existsSync(file)) {
140
+ // Materialise defaults so the editor has something to show.
141
+ updatePersistedConfig({});
142
+ }
143
+ const backup = getConfigBackupPath();
144
+ try {
145
+ copyFileSync(file, backup);
146
+ }
147
+ catch (err) {
148
+ const reason = err instanceof Error ? err.message : String(err);
149
+ process.stderr.write(`Could not write ${backup}: ${reason}\n`);
150
+ process.exitCode = 1;
151
+ return;
152
+ }
153
+ const editor = pickEditor();
154
+ // A shell is needed on Windows so an `EDITOR` pointing at a `.cmd` / `.bat`
155
+ // launcher (`code.cmd`, most of them) still runs — but a shell re-splits the
156
+ // command line, so the path has to be quoted or `C:\Users\Jane Doe\...` would
157
+ // reach the editor as two arguments and open two empty buffers.
158
+ const useShell = process.platform === 'win32';
159
+ const target = useShell ? `"${file}"` : file;
160
+ const result = spawnSync(editor, [target], { stdio: 'inherit', shell: useShell });
161
+ if (result.error) {
162
+ process.stderr.write(`Could not launch "${editor}": ${result.error.message}\n`);
163
+ process.exitCode = 1;
164
+ return;
165
+ }
166
+ try {
167
+ JSON.parse(readFileSync(file, 'utf-8'));
168
+ process.stdout.write(`${file} saved.\n`);
169
+ }
170
+ catch (err) {
171
+ process.stderr.write(`${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}).\n` +
172
+ `The previous version is still at ${backup}.\n`);
173
+ process.exitCode = 1;
174
+ }
175
+ }
176
+ export function runConfigHome() {
177
+ process.stdout.write(`${getHomeRoot()}\n`);
178
+ if (isHomeOverridden())
179
+ process.stdout.write(' (overridden by ARAGON_HOME)\n');
180
+ const breadcrumb = getHomeMigrationBreadcrumbPath();
181
+ if (!existsSync(breadcrumb))
182
+ return;
183
+ // The env-paths directories are left in place on purpose so reinstalling an
184
+ // older build stays lossless; pointing at them is how the user knows they can
185
+ // be deleted once the new location has proved itself.
186
+ try {
187
+ const parsed = JSON.parse(readFileSync(breadcrumb, 'utf-8'));
188
+ if (parsed.from?.config)
189
+ process.stdout.write(` previously: ${parsed.from.config}\n`);
190
+ if (parsed.from?.data)
191
+ process.stdout.write(` previously: ${parsed.from.data}\n`);
192
+ }
193
+ catch {
194
+ // A damaged breadcrumb is a lost diagnostic, not an error worth reporting.
195
+ }
196
+ }
@@ -5,7 +5,13 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME.
8
+ * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN,
9
+ * ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
+ *
11
+ * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
12
+ * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
13
+ * supply it. README and §5.2 of the design say so explicitly — otherwise "I set
14
+ * ARAGON_HOME in .env and it did nothing" is an unexplainable bug report.
9
15
  */
10
16
  import { type PersistedConfig } from './schema.js';
11
17
  /**
@@ -5,11 +5,19 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME.
8
+ * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN,
9
+ * ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
+ *
11
+ * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
12
+ * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
13
+ * supply it. README and §5.2 of the design say so explicitly — otherwise "I set
14
+ * ARAGON_HOME in .env and it did nothing" is an unexplainable bug report.
9
15
  */
10
16
  import { join } from 'node:path';
11
17
  import process from 'node:process';
12
18
  import dotenv from 'dotenv';
19
+ import { registerSecretsFrom } from '../logging/secret-registry.js';
20
+ import { clampLogLevel } from '../logging/levels.js';
13
21
  import { clampTheme, clampThinkingLevel, coerceMaxTokens, } from './schema.js';
14
22
  /**
15
23
  * Load a project `.env` from the given directory into `process.env` (existing
@@ -63,6 +71,20 @@ export function readEnvConfig() {
63
71
  if (fullscreen !== undefined && fullscreen.length > 0) {
64
72
  partial.fullscreen = fullscreen === '1' || fullscreen === 'true' || fullscreen === 'on' || fullscreen === 'yes';
65
73
  }
74
+ // ARAGON_MOUSE=0 turns off wheel region routing. Parsed by the POSITIVE list
75
+ // above, exactly as ARAGON_FULLSCREEN is — NOT by `envBool` below, which uses
76
+ // a negative list. The two disagree on `ARAGON_MOUSE=disable`, and `mouse` is
77
+ // modelled on `--fullscreen`, so it uses the reader `--fullscreen` uses.
78
+ const mouse = process.env.ARAGON_MOUSE?.trim().toLowerCase();
79
+ if (mouse !== undefined && mouse.length > 0) {
80
+ partial.mouse = mouse === '1' || mouse === 'true' || mouse === 'on' || mouse === 'yes';
81
+ }
82
+ // ARAGON_PLAN=1 starts the session in PLAN mode — the same channel story as
83
+ // ARAGON_FULLSCREEN, for terminals reached through a wrapper that owns argv.
84
+ const plan = process.env.ARAGON_PLAN?.trim().toLowerCase();
85
+ if (plan !== undefined && plan.length > 0) {
86
+ partial.planModeDefault = plan === '1' || plan === 'true' || plan === 'on' || plan === 'yes';
87
+ }
66
88
  // ARAGON_SKILLS=0 is the kill switch for the whole skill subsystem; the other
67
89
  // two skill env vars (ARAGON_SKILLS_PATH, ARAGON_SKILLS_DISABLED) are read where
68
90
  // they are used — the search-root resolver and the service respectively —
@@ -74,12 +96,42 @@ export function readEnvConfig() {
74
96
  enabled: !(skills === '0' || skills === 'false' || skills === 'off' || skills === 'no'),
75
97
  };
76
98
  }
99
+ const log = readEnvLogConfig();
100
+ if (log)
101
+ partial.log = log;
77
102
  const apiKeys = {};
78
103
  for (const [prov, names] of Object.entries(PROVIDER_ENV_KEYS)) {
79
104
  apiKeys[prov] = firstEnv(names);
80
105
  }
106
+ // Registration site 2 (§4.4.3). A key that only ever lives in the environment
107
+ // never passes through `updatePersistedConfig`, so this is its only chance to
108
+ // reach the redactor's backstop.
109
+ registerSecretsFrom(apiKeys);
81
110
  return { partial, apiKeys };
82
111
  }
112
+ /** Interpret `0/false/off/no` as false; anything else non-empty as true. */
113
+ function envBool(value) {
114
+ const v = value.trim().toLowerCase();
115
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
116
+ }
117
+ /**
118
+ * Assemble the `log` section from the environment, or `undefined` when the user
119
+ * said nothing about it. Same shape as the `partial.skills` assembly above: a
120
+ * PARTIAL section, safe only because every consumer deep-merges it.
121
+ */
122
+ function readEnvLogConfig() {
123
+ const out = {};
124
+ const level = process.env.ARAGON_LOG_LEVEL?.trim();
125
+ if (level)
126
+ out.level = clampLogLevel(level, 'info');
127
+ const toFile = process.env.ARAGON_LOG_FILE?.trim();
128
+ if (toFile !== undefined && toFile.length > 0)
129
+ out.toFile = envBool(toFile);
130
+ const dir = process.env.ARAGON_LOG_DIR?.trim();
131
+ if (dir)
132
+ out.dir = dir;
133
+ return Object.keys(out).length > 0 ? out : undefined;
134
+ }
83
135
  /** Skill names disabled for this run via `ARAGON_SKILLS_DISABLED` (comma-separated). */
84
136
  export function readEnvDisabledSkills() {
85
137
  const raw = process.env.ARAGON_SKILLS_DISABLED;
@@ -27,6 +27,8 @@ export interface CliFlags {
27
27
  compact?: boolean;
28
28
  /** `--hints` ⇒ true, `--no-hints` ⇒ false, absent ⇒ undefined. */
29
29
  hints?: boolean;
30
+ /** `--mouse` ⇒ true, `--no-mouse` ⇒ false, absent ⇒ undefined. */
31
+ mouse?: boolean;
30
32
  /** `--no-skills` ⇒ false. Turns the whole skill subsystem off (§7.5). */
31
33
  skills?: boolean;
32
34
  /** `--skill <name>` ×N — force Level 2 injection for this run. */
@@ -35,6 +37,14 @@ export interface CliFlags {
35
37
  skillsYes?: boolean;
36
38
  /** `--skill-tool-policy <mode>` — override the tool ceiling for this run (§5.5). */
37
39
  skillToolPolicy?: string;
40
+ /** `--plan` ⇒ true, `--no-plan` ⇒ false, absent ⇒ undefined (plan-mode §4.4). */
41
+ plan?: boolean;
42
+ /** `--log-level <lv>` — this run only, never persisted. */
43
+ logLevel?: string;
44
+ /** `--log-file` ⇒ true, `--no-log-file` ⇒ false, absent ⇒ undefined. */
45
+ logFile?: boolean;
46
+ /** `--log-dir <dir>` — this run only. */
47
+ logDir?: string;
38
48
  }
39
49
  /**
40
50
  * Resolve the effective config by merging all layers, then derive the runtime
@@ -6,9 +6,11 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampDensity, clampSkillsConfig, clampTheme, clampThinkingLevel, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTheme, clampThinkingLevel, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isSkillsToolPolicyMode, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
+ import { getLogger } from '../logging/logger.js';
13
+ import { registerSecret, registerSecretsFrom } from '../logging/secret-registry.js';
12
14
  import { detectCapabilities } from '../ui/capabilities.js';
13
15
  /** Interpret a boolean-ish env value (`1/true/on/yes`) as `true`. */
14
16
  function envFlagTrue(value) {
@@ -31,6 +33,57 @@ function resolveFullscreen(flags, env, file) {
31
33
  return false;
32
34
  return undefined;
33
35
  }
36
+ /**
37
+ * Resolve wheel region routing: flag › env › file › default `true`.
38
+ *
39
+ * THIS NEEDS ITS OWN RESOLVER; it does not "match every other boolean" in this
40
+ * file. `hints` is `flags.hints !== undefined ? … : file.hints ?? DEFAULT` and
41
+ * reads NO ENV AT ALL — copying that line would ship `ARAGON_MOUSE` as
42
+ * documented-but-dead (P1-9). Only `fullscreen` has an env layer, and only
43
+ * because `resolveFullscreen` above was written to give it one.
44
+ *
45
+ * Unlike `fullscreen` this is a plain boolean rather than a tri-state: there
46
+ * are no heuristics for an explicit `true` to override, so a persisted `true`
47
+ * is just a value and not an opinion that has to be told apart from silence.
48
+ */
49
+ function resolveMouse(flags, env, file) {
50
+ if (flags.mouse !== undefined)
51
+ return flags.mouse;
52
+ if (env.mouse !== undefined)
53
+ return env.mouse;
54
+ if (file.mouse !== undefined)
55
+ return file.mouse;
56
+ return DEFAULT_CONFIG.mouse;
57
+ }
58
+ /**
59
+ * Resolve whether logs reach a file: flag › env › an explicit `false` in the
60
+ * file › default `true`.
61
+ *
62
+ * Shaped exactly like `resolveFullscreen` above, and for the same reason: the
63
+ * config file's DEFAULT `true` must not be mistaken for an opinion, or
64
+ * `--no-log-file` would be the only way to ever turn logging off and a stored
65
+ * `toFile: false` would be unreachable.
66
+ */
67
+ function resolveLogToFile(flags, env, file) {
68
+ if (flags.logFile !== undefined)
69
+ return flags.logFile;
70
+ if (env.log?.toFile !== undefined)
71
+ return env.log.toFile;
72
+ if (file.log?.toFile === false)
73
+ return false;
74
+ return true;
75
+ }
76
+ /** Merge the `log` section across all four layers (defaults › file › env › flags). */
77
+ function resolveLogConfig(flags, env, file) {
78
+ const merged = clampLogConfig({
79
+ ...DEFAULT_CONFIG.log,
80
+ ...(file.log ?? {}),
81
+ ...(env.log ?? {}),
82
+ ...(flags.logLevel ? { level: flags.logLevel } : {}),
83
+ ...(flags.logDir ? { dir: flags.logDir } : {}),
84
+ });
85
+ return { ...merged, toFile: resolveLogToFile(flags, env, file) };
86
+ }
34
87
  // ---------------------------------------------------------------------------
35
88
  // loadConfig
36
89
  // ---------------------------------------------------------------------------
@@ -49,8 +102,18 @@ export function loadConfig(flags = {}) {
49
102
  const cwd = flags.cwd ? flags.cwd : process.cwd();
50
103
  // `.env` must be loaded before reading env overrides.
51
104
  loadDotenv(cwd);
52
- const file = readConfigFile() ?? {};
105
+ const read = readConfigFile();
106
+ const file = read.config ?? {};
53
107
  const env = readEnvConfig();
108
+ // A config file that exists but will not parse used to reset EVERY setting
109
+ // with nothing anywhere explaining it. The fallback is unchanged; what is new
110
+ // is that it leaves a trace. The record alone is not the explanation, so the
111
+ // two owners of a user-visible channel say it too: `main()` writes one stderr
112
+ // line (headless, inline and every subcommand) and `App.tsx` raises a toast
113
+ // (full-screen, where the alternate screen wipes that line away).
114
+ if (read.parseError) {
115
+ getLogger().error('config', 'config_parse_failed', { error: read.parseError });
116
+ }
54
117
  const provider = pick(flags.provider, env.partial.provider, file.provider) ?? DEFAULT_CONFIG.provider;
55
118
  const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
56
119
  const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
@@ -66,6 +129,13 @@ export function loadConfig(flags = {}) {
66
129
  : clampDensity(file.density, DEFAULT_CONFIG.density);
67
130
  const hints = flags.hints !== undefined ? flags.hints : file.hints ?? DEFAULT_CONFIG.hints;
68
131
  const confirmTools = flags.confirm !== undefined ? flags.confirm : file.confirmTools ?? DEFAULT_CONFIG.confirmTools;
132
+ // Plan mode: defaults -> file -> env -> flags, the same order as every other
133
+ // field. `pick` keeps an explicit `false`, so `--no-plan` really does override
134
+ // a config file that turned plan mode on.
135
+ const startInPlanMode = pick(flags.plan, env.partial.planModeDefault, file.planModeDefault) ??
136
+ DEFAULT_CONFIG.planModeDefault;
137
+ const planModeMaxAskRounds = clampAskRounds(file.planModeMaxAskRounds, DEFAULT_CONFIG.planModeMaxAskRounds);
138
+ const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
69
139
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
70
140
  let idleTimeoutMs = coercePositiveInt(pick(flags.idleTimeout, file.idleTimeoutMs), DEFAULT_CONFIG.idleTimeoutMs);
71
141
  // Timeout invariant (R1): idle must be strictly greater than the tool ceiling
@@ -95,6 +165,11 @@ export function loadConfig(flags = {}) {
95
165
  ...new Set([...(file.skills?.disabled ?? []), ...readEnvDisabledSkills()]),
96
166
  ],
97
167
  });
168
+ // Registration site 3 (§4.4.3): the merged view plus the one-shot `--api-key`,
169
+ // which exists nowhere else. Overlaps with sites 1 and 2 on purpose —
170
+ // registering twice costs a `Set.add`, missing once costs a credential.
171
+ registerSecretsFrom(apiKeys);
172
+ registerSecret(flags.apiKey);
98
173
  const color = flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
99
174
  // `--no-color` implies calmer chrome; env / file may also opt in explicitly.
100
175
  const reducedMotion = envFlagTrue(process.env.ARAGON_REDUCED_MOTION) ||
@@ -124,7 +199,11 @@ export function loadConfig(flags = {}) {
124
199
  promptHistory: file.promptHistory ?? [],
125
200
  density,
126
201
  hints,
202
+ mouse: resolveMouse(flags, env.partial, file),
127
203
  submitCount: coercePositiveInt(file.submitCount, 0),
204
+ startInPlanMode,
205
+ planModeMaxAskRounds,
206
+ planModeHumanTimeoutMs,
128
207
  skills,
129
208
  skillsRuntime: {
130
209
  forcedSkills: flags.skill ?? [],
@@ -137,6 +216,7 @@ export function loadConfig(flags = {}) {
137
216
  ? { toolPolicy: flags.skillToolPolicy }
138
217
  : {}),
139
218
  },
219
+ log: resolveLogConfig(flags, env.partial, file),
140
220
  cwd,
141
221
  color,
142
222
  colorLevel,