@aragon-agent/cli 0.5.0 → 0.5.1

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 (82) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +227 -3
  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 +183 -20
  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 +45 -1
  23. package/dist/config/load.d.ts +8 -0
  24. package/dist/config/load.js +59 -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 +61 -0
  30. package/dist/config/schema.js +83 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/logging/cli-commands.d.ts +25 -0
  34. package/dist/logging/cli-commands.js +221 -0
  35. package/dist/logging/file-sink.d.ts +114 -0
  36. package/dist/logging/file-sink.js +326 -0
  37. package/dist/logging/install.d.ts +163 -0
  38. package/dist/logging/install.js +302 -0
  39. package/dist/logging/levels.d.ts +40 -0
  40. package/dist/logging/levels.js +59 -0
  41. package/dist/logging/logger.d.ts +110 -0
  42. package/dist/logging/logger.js +237 -0
  43. package/dist/logging/redact.d.ts +28 -0
  44. package/dist/logging/redact.js +90 -0
  45. package/dist/logging/secret-registry.d.ts +30 -0
  46. package/dist/logging/secret-registry.js +55 -0
  47. package/dist/skills/paths.d.ts +2 -4
  48. package/dist/skills/paths.js +7 -9
  49. package/dist/tools/human-input.d.ts +179 -0
  50. package/dist/tools/human-input.js +227 -0
  51. package/dist/tools/index.d.ts +35 -3
  52. package/dist/tools/index.js +96 -9
  53. package/dist/tools/plan-tools.d.ts +64 -0
  54. package/dist/tools/plan-tools.js +198 -0
  55. package/dist/ui/App.d.ts +4 -1
  56. package/dist/ui/App.js +182 -11
  57. package/dist/ui/Composer.d.ts +4 -1
  58. package/dist/ui/Composer.js +26 -7
  59. package/dist/ui/ModeChip.d.ts +29 -0
  60. package/dist/ui/ModeChip.js +15 -0
  61. package/dist/ui/PromptInput.d.ts +8 -1
  62. package/dist/ui/PromptInput.js +12 -2
  63. package/dist/ui/StatusBar.d.ts +15 -0
  64. package/dist/ui/StatusBar.js +10 -2
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  66. package/dist/ui/entries/ToolPreview.js +64 -0
  67. package/dist/ui/glyphs.d.ts +9 -0
  68. package/dist/ui/glyphs.js +8 -0
  69. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  70. package/dist/ui/layout/wrap-rows.js +74 -0
  71. package/dist/ui/overlays/HelpOverlay.js +13 -0
  72. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  73. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  74. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  75. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  76. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  77. package/dist/ui/overlays/SettingsScreen.js +8 -1
  78. package/dist/ui/use-startup-notices.d.ts +15 -0
  79. package/dist/ui/use-startup-notices.js +38 -0
  80. package/package.json +3 -2
  81. package/runtime/insecure-tls-warning.cjs +62 -0
  82. 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_PLAN, ARAGON_LOG_LEVEL,
9
+ * 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_PLAN, ARAGON_LOG_LEVEL,
9
+ * 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,12 @@ 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_PLAN=1 starts the session in PLAN mode — the same channel story as
75
+ // ARAGON_FULLSCREEN, for terminals reached through a wrapper that owns argv.
76
+ const plan = process.env.ARAGON_PLAN?.trim().toLowerCase();
77
+ if (plan !== undefined && plan.length > 0) {
78
+ partial.planModeDefault = plan === '1' || plan === 'true' || plan === 'on' || plan === 'yes';
79
+ }
66
80
  // ARAGON_SKILLS=0 is the kill switch for the whole skill subsystem; the other
67
81
  // two skill env vars (ARAGON_SKILLS_PATH, ARAGON_SKILLS_DISABLED) are read where
68
82
  // they are used — the search-root resolver and the service respectively —
@@ -74,12 +88,42 @@ export function readEnvConfig() {
74
88
  enabled: !(skills === '0' || skills === 'false' || skills === 'off' || skills === 'no'),
75
89
  };
76
90
  }
91
+ const log = readEnvLogConfig();
92
+ if (log)
93
+ partial.log = log;
77
94
  const apiKeys = {};
78
95
  for (const [prov, names] of Object.entries(PROVIDER_ENV_KEYS)) {
79
96
  apiKeys[prov] = firstEnv(names);
80
97
  }
98
+ // Registration site 2 (§4.4.3). A key that only ever lives in the environment
99
+ // never passes through `updatePersistedConfig`, so this is its only chance to
100
+ // reach the redactor's backstop.
101
+ registerSecretsFrom(apiKeys);
81
102
  return { partial, apiKeys };
82
103
  }
104
+ /** Interpret `0/false/off/no` as false; anything else non-empty as true. */
105
+ function envBool(value) {
106
+ const v = value.trim().toLowerCase();
107
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
108
+ }
109
+ /**
110
+ * Assemble the `log` section from the environment, or `undefined` when the user
111
+ * said nothing about it. Same shape as the `partial.skills` assembly above: a
112
+ * PARTIAL section, safe only because every consumer deep-merges it.
113
+ */
114
+ function readEnvLogConfig() {
115
+ const out = {};
116
+ const level = process.env.ARAGON_LOG_LEVEL?.trim();
117
+ if (level)
118
+ out.level = clampLogLevel(level, 'info');
119
+ const toFile = process.env.ARAGON_LOG_FILE?.trim();
120
+ if (toFile !== undefined && toFile.length > 0)
121
+ out.toFile = envBool(toFile);
122
+ const dir = process.env.ARAGON_LOG_DIR?.trim();
123
+ if (dir)
124
+ out.dir = dir;
125
+ return Object.keys(out).length > 0 ? out : undefined;
126
+ }
83
127
  /** Skill names disabled for this run via `ARAGON_SKILLS_DISABLED` (comma-separated). */
84
128
  export function readEnvDisabledSkills() {
85
129
  const raw = process.env.ARAGON_SKILLS_DISABLED;
@@ -35,6 +35,14 @@ export interface CliFlags {
35
35
  skillsYes?: boolean;
36
36
  /** `--skill-tool-policy <mode>` — override the tool ceiling for this run (§5.5). */
37
37
  skillToolPolicy?: string;
38
+ /** `--plan` ⇒ true, `--no-plan` ⇒ false, absent ⇒ undefined (plan-mode §4.4). */
39
+ plan?: boolean;
40
+ /** `--log-level <lv>` — this run only, never persisted. */
41
+ logLevel?: string;
42
+ /** `--log-file` ⇒ true, `--no-log-file` ⇒ false, absent ⇒ undefined. */
43
+ logFile?: boolean;
44
+ /** `--log-dir <dir>` — this run only. */
45
+ logDir?: string;
38
46
  }
39
47
  /**
40
48
  * 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,35 @@ function resolveFullscreen(flags, env, file) {
31
33
  return false;
32
34
  return undefined;
33
35
  }
36
+ /**
37
+ * Resolve whether logs reach a file: flag › env › an explicit `false` in the
38
+ * file › default `true`.
39
+ *
40
+ * Shaped exactly like `resolveFullscreen` above, and for the same reason: the
41
+ * config file's DEFAULT `true` must not be mistaken for an opinion, or
42
+ * `--no-log-file` would be the only way to ever turn logging off and a stored
43
+ * `toFile: false` would be unreachable.
44
+ */
45
+ function resolveLogToFile(flags, env, file) {
46
+ if (flags.logFile !== undefined)
47
+ return flags.logFile;
48
+ if (env.log?.toFile !== undefined)
49
+ return env.log.toFile;
50
+ if (file.log?.toFile === false)
51
+ return false;
52
+ return true;
53
+ }
54
+ /** Merge the `log` section across all four layers (defaults › file › env › flags). */
55
+ function resolveLogConfig(flags, env, file) {
56
+ const merged = clampLogConfig({
57
+ ...DEFAULT_CONFIG.log,
58
+ ...(file.log ?? {}),
59
+ ...(env.log ?? {}),
60
+ ...(flags.logLevel ? { level: flags.logLevel } : {}),
61
+ ...(flags.logDir ? { dir: flags.logDir } : {}),
62
+ });
63
+ return { ...merged, toFile: resolveLogToFile(flags, env, file) };
64
+ }
34
65
  // ---------------------------------------------------------------------------
35
66
  // loadConfig
36
67
  // ---------------------------------------------------------------------------
@@ -49,8 +80,18 @@ export function loadConfig(flags = {}) {
49
80
  const cwd = flags.cwd ? flags.cwd : process.cwd();
50
81
  // `.env` must be loaded before reading env overrides.
51
82
  loadDotenv(cwd);
52
- const file = readConfigFile() ?? {};
83
+ const read = readConfigFile();
84
+ const file = read.config ?? {};
53
85
  const env = readEnvConfig();
86
+ // A config file that exists but will not parse used to reset EVERY setting
87
+ // with nothing anywhere explaining it. The fallback is unchanged; what is new
88
+ // is that it leaves a trace. The record alone is not the explanation, so the
89
+ // two owners of a user-visible channel say it too: `main()` writes one stderr
90
+ // line (headless, inline and every subcommand) and `App.tsx` raises a toast
91
+ // (full-screen, where the alternate screen wipes that line away).
92
+ if (read.parseError) {
93
+ getLogger().error('config', 'config_parse_failed', { error: read.parseError });
94
+ }
54
95
  const provider = pick(flags.provider, env.partial.provider, file.provider) ?? DEFAULT_CONFIG.provider;
55
96
  const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
56
97
  const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
@@ -66,6 +107,13 @@ export function loadConfig(flags = {}) {
66
107
  : clampDensity(file.density, DEFAULT_CONFIG.density);
67
108
  const hints = flags.hints !== undefined ? flags.hints : file.hints ?? DEFAULT_CONFIG.hints;
68
109
  const confirmTools = flags.confirm !== undefined ? flags.confirm : file.confirmTools ?? DEFAULT_CONFIG.confirmTools;
110
+ // Plan mode: defaults -> file -> env -> flags, the same order as every other
111
+ // field. `pick` keeps an explicit `false`, so `--no-plan` really does override
112
+ // a config file that turned plan mode on.
113
+ const startInPlanMode = pick(flags.plan, env.partial.planModeDefault, file.planModeDefault) ??
114
+ DEFAULT_CONFIG.planModeDefault;
115
+ const planModeMaxAskRounds = clampAskRounds(file.planModeMaxAskRounds, DEFAULT_CONFIG.planModeMaxAskRounds);
116
+ const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
69
117
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
70
118
  let idleTimeoutMs = coercePositiveInt(pick(flags.idleTimeout, file.idleTimeoutMs), DEFAULT_CONFIG.idleTimeoutMs);
71
119
  // Timeout invariant (R1): idle must be strictly greater than the tool ceiling
@@ -95,6 +143,11 @@ export function loadConfig(flags = {}) {
95
143
  ...new Set([...(file.skills?.disabled ?? []), ...readEnvDisabledSkills()]),
96
144
  ],
97
145
  });
146
+ // Registration site 3 (§4.4.3): the merged view plus the one-shot `--api-key`,
147
+ // which exists nowhere else. Overlaps with sites 1 and 2 on purpose —
148
+ // registering twice costs a `Set.add`, missing once costs a credential.
149
+ registerSecretsFrom(apiKeys);
150
+ registerSecret(flags.apiKey);
98
151
  const color = flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
99
152
  // `--no-color` implies calmer chrome; env / file may also opt in explicitly.
100
153
  const reducedMotion = envFlagTrue(process.env.ARAGON_REDUCED_MOTION) ||
@@ -125,6 +178,9 @@ export function loadConfig(flags = {}) {
125
178
  density,
126
179
  hints,
127
180
  submitCount: coercePositiveInt(file.submitCount, 0),
181
+ startInPlanMode,
182
+ planModeMaxAskRounds,
183
+ planModeHumanTimeoutMs,
128
184
  skills,
129
185
  skillsRuntime: {
130
186
  forcedSkills: flags.skill ?? [],
@@ -137,6 +193,7 @@ export function loadConfig(flags = {}) {
137
193
  ? { toolPolicy: flags.skillToolPolicy }
138
194
  : {}),
139
195
  },
196
+ log: resolveLogConfig(flags, env.partial, file),
140
197
  cwd,
141
198
  color,
142
199
  colorLevel,