@aragon-agent/cli 0.4.3

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 (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Config schema — the persisted config-file shape, the in-memory effective
3
+ * config, sane defaults, and small validation/masking helpers.
4
+ *
5
+ * See spec §3.5 (resolution pipeline), §6.1 (config file), and §3.8 (timeout
6
+ * invariant).
7
+ */
8
+ import type { ThinkingLevel } from '@aragon-agent/core';
9
+ /**
10
+ * `ModelRegistry` seeds builtin lists for five providers, but `initProviders()`
11
+ * registers only three adapters. Running a model whose provider has no adapter
12
+ * throws `Unknown LLM provider`, so the CLI only ever offers these three.
13
+ */
14
+ export declare const ADAPTER_PROVIDERS: readonly ["anthropic", "openai", "google"];
15
+ export type AdapterProviderId = (typeof ADAPTER_PROVIDERS)[number];
16
+ export declare function isAdapterProvider(id: string): id is AdapterProviderId;
17
+ export declare const THINKING_LEVELS: ThinkingLevel[];
18
+ export declare function isThinkingLevel(v: unknown): v is ThinkingLevel;
19
+ export declare function clampThinkingLevel(v: unknown, fallback: ThinkingLevel): ThinkingLevel;
20
+ export declare const THEME_NAMES: readonly ["auto", "warm", "cool", "light"];
21
+ export type ThemeName = (typeof THEME_NAMES)[number];
22
+ /** The v0.3.0 name for what is now `cool`. Accepted forever as an alias. */
23
+ export declare const LEGACY_DARK_THEME = "dark";
24
+ export declare function isThemeName(v: unknown): v is ThemeName;
25
+ /**
26
+ * THE single gate for every theme write (spec §4.5 / R-4).
27
+ *
28
+ * All three write paths funnel through here — config resolution (`load.ts`),
29
+ * `aragon config set theme`, and the `/theme` slash command — which is why the
30
+ * `dark -> cool` migration lives here and nowhere else. Putting it in a
31
+ * `load.ts` normalization step instead would migrate what users *read* but not
32
+ * what they *write*, producing the worst kind of bug: "the flag works but the
33
+ * setting won't stick."
34
+ *
35
+ * An unrecognized value still falls back silently (plus a warn toast at the call
36
+ * site). Turning that into a hard exit would be an unannounced breaking change
37
+ * and would not match `clampThinkingLevel` / `clampTranscriptWindow`.
38
+ */
39
+ export declare function clampTheme(v: unknown, fallback: ThemeName): ThemeName;
40
+ export declare const DENSITY_MODES: readonly ["comfortable", "compact"];
41
+ export type DensityMode = (typeof DENSITY_MODES)[number];
42
+ export declare function clampDensity(v: unknown, fallback: DensityMode): DensityMode;
43
+ /**
44
+ * Submissions after which the composer hint collapses to `? help` (§4.6).
45
+ * Only the IDLE hint fades; the running hint carries the abort key and is
46
+ * always shown in full.
47
+ */
48
+ export declare const HINT_FADE_AFTER = 8;
49
+ export declare const CONFIG_VERSION = 1;
50
+ export declare const DEFAULT_MODEL = "claude-sonnet-4-5-20250929";
51
+ /** Per-tool executor ceiling (§3.8). Headroom for typical installs/builds. */
52
+ export declare const DEFAULT_TOOL_TIMEOUT_MS = 180000;
53
+ /** Extra margin so idle timeout is strictly greater than the tool ceiling. */
54
+ export declare const IDLE_TIMEOUT_MARGIN_MS = 30000;
55
+ /** Watchdog idle timeout (§3.8) — must stay ≥ toolTimeout so long tools survive. */
56
+ export declare const DEFAULT_IDLE_TIMEOUT_MS: number;
57
+ /** Cap on persisted prompt history / recent-model MRU lists. */
58
+ export declare const PROMPT_HISTORY_CAP = 100;
59
+ export declare const RECENT_MODELS_CAP = 10;
60
+ /**
61
+ * How many trailing entries the full-screen viewport renders (invariant I-3).
62
+ * Yoga lays out every child even when `overflow: hidden` clips it, so the cost
63
+ * of a long session is linear in the entry count regardless of visibility. The
64
+ * reducer and `/save` keep the full history — this is a render-layer bound only.
65
+ */
66
+ export declare const DEFAULT_TRANSCRIPT_WINDOW = 300;
67
+ export declare const MIN_TRANSCRIPT_WINDOW = 50;
68
+ export declare const MAX_TRANSCRIPT_WINDOW = 2000;
69
+ export declare function clampTranscriptWindow(v: unknown, fallback: number): number;
70
+ export interface SkillsConfig {
71
+ enabled: boolean;
72
+ /** Skill names the user has switched off. */
73
+ disabled: string[];
74
+ /** Gate `skill_install` / `skill_create` behind a human confirmation (D12). */
75
+ requireApproval: boolean;
76
+ /** Hosts an install may fetch from. Anything else is refused (SSRF guard). */
77
+ allowedHosts: string[];
78
+ /** Project-relative skill roots, in ascending precedence. */
79
+ projectDirs: string[];
80
+ /** Absolute, normalized directories the user has trusted (D13 / §9.3). */
81
+ trustedProjectDirs: string[];
82
+ /** D19: BYTES, not characters. */
83
+ catalogMaxBytes: number;
84
+ /** D19: BYTES, not characters. Upper bound derives from SKILL_RESULT_MAX_BYTES. */
85
+ bodyMaxBytes: number;
86
+ /**
87
+ * What to do when SKILL.md no longer matches the copy that was approved (§8.2).
88
+ *
89
+ * Defaults to `warn`, NOT `strict` (D-A13). Hand-editing an installed skill is
90
+ * a legitimate workflow — the same one `update --force` exists to protect —
91
+ * and a default that silently removed the edited skill from the catalog would
92
+ * present as "I changed one line and it disappeared".
93
+ */
94
+ integrity: SkillsIntegrityMode;
95
+ /**
96
+ * Keep local per-skill use counters to rank the catalog (§9). Name, count and
97
+ * timestamp only; never transmitted. `false` disables all reads and writes.
98
+ */
99
+ usageTracking: boolean;
100
+ /**
101
+ * How hard `allowed-tools` bites (§5.5).
102
+ *
103
+ * Defaults to `enforce`, NOT `warn` — the opposite of `integrity`, and the two
104
+ * are not comparable. A false integrity alarm punishes a user for legitimately
105
+ * editing a file they own; a false ceiling refusal requires a skill AUTHOR to
106
+ * have under-declared what their own skill uses, which is a bug in the skill
107
+ * and one that `doctor` reports before it can bite. Add the three narrowings
108
+ * that already apply — one turn only, only for skills that declared anything,
109
+ * and every read-only tool in the floor — and shipping it off by default would
110
+ * just be a second decorative field.
111
+ */
112
+ toolPolicy: SkillsToolPolicyMode;
113
+ }
114
+ export declare const SKILLS_INTEGRITY_MODES: readonly ["off", "warn", "strict"];
115
+ export type SkillsIntegrityMode = (typeof SKILLS_INTEGRITY_MODES)[number];
116
+ export declare function clampSkillsIntegrity(v: unknown, fallback: SkillsIntegrityMode): SkillsIntegrityMode;
117
+ export declare const SKILLS_TOOL_POLICY_MODES: readonly ["off", "warn", "enforce"];
118
+ export type SkillsToolPolicyMode = (typeof SKILLS_TOOL_POLICY_MODES)[number];
119
+ export declare function isSkillsToolPolicyMode(v: unknown): v is SkillsToolPolicyMode;
120
+ export declare function clampSkillsToolPolicy(v: unknown, fallback: SkillsToolPolicyMode): SkillsToolPolicyMode;
121
+ export declare const DEFAULT_ALLOWED_SKILL_HOSTS: string[];
122
+ export declare const DEFAULT_SKILLS_CONFIG: SkillsConfig;
123
+ /**
124
+ * THE single gate for every skills-config read AND write (§10.3.1).
125
+ *
126
+ * Shared by `loadConfig()`, `loadPersistedConfig()` and `updatePersistedConfig()`
127
+ * on purpose: hardening only the read path would leave a corrupt value on disk
128
+ * that silently reverts to a default on every launch — the failure looks like
129
+ * "my setting won't stick" with no error anywhere.
130
+ */
131
+ export declare function clampSkillsConfig(raw: unknown): SkillsConfig;
132
+ /**
133
+ * Run-time-only skill switches (§7.5). These come from flags / env and are
134
+ * NEVER persisted, which is why they do not live in `SkillsConfig`.
135
+ */
136
+ export interface SkillsRuntimeOptions {
137
+ /** `--skill <name>` ×N — force Level 2 injection for this run only. */
138
+ forcedSkills: string[];
139
+ /** `--skills-yes` — approve installs for this run only. */
140
+ approveAll: boolean;
141
+ /**
142
+ * `--skill-tool-policy <mode>` — override the ceiling mode for this run.
143
+ *
144
+ * Sits BETWEEN the session override and the config file (D-G19), not on top of
145
+ * everything: the refusal text tells the user to run `/skills policy off`, and
146
+ * a flag that outranked that command would make the documented escape hatch a
147
+ * no-op with no message to explain it.
148
+ */
149
+ toolPolicy?: SkillsToolPolicyMode;
150
+ }
151
+ export declare const DEFAULT_SKILLS_RUNTIME: SkillsRuntimeOptions;
152
+ export interface PersistedConfig {
153
+ version: number;
154
+ provider: string;
155
+ model: string;
156
+ baseUrl: string | null;
157
+ thinkingLevel: ThinkingLevel;
158
+ maxTokens: number | null;
159
+ theme: ThemeName;
160
+ /** Replace spinners with a static glyph for calmer, low-motion output. */
161
+ reducedMotion: boolean;
162
+ /** Full-screen TUI (still subject to the automatic downgrades in §4.1). */
163
+ fullscreen: boolean;
164
+ /** Replay a plain-text session summary after leaving the alternate screen. */
165
+ exitTranscript: boolean;
166
+ /** Trailing entries the full-screen viewport renders (I-3). */
167
+ transcriptWindow: number;
168
+ confirmTools: boolean;
169
+ toolTimeoutMs: number;
170
+ idleTimeoutMs: number;
171
+ /** provider -> key. Masked in the UI; the file is written 0600 on POSIX. */
172
+ apiKeys: Record<string, string | null>;
173
+ recentModels: string[];
174
+ promptHistory: string[];
175
+ /** Entry spacing: `comfortable` keeps a blank line at turn boundaries (§4.3). */
176
+ density: DensityMode;
177
+ /** Show the composer hint row at all. */
178
+ hints: boolean;
179
+ /** Lifetime submit count driving the progressive-disclosure fade (§4.6). */
180
+ submitCount: number;
181
+ /** Skill system settings. The SECOND nested object in this file after apiKeys. */
182
+ skills: SkillsConfig;
183
+ }
184
+ export declare const DEFAULT_CONFIG: PersistedConfig;
185
+ export interface CliConfig {
186
+ provider: string;
187
+ model: string;
188
+ baseUrl?: string;
189
+ thinkingLevel: ThinkingLevel;
190
+ maxTokens?: number;
191
+ theme: ThemeName;
192
+ reducedMotion: boolean;
193
+ /**
194
+ * TRI-STATE, and deliberately so (spec §4.1 vs §6.2):
195
+ * `false` — an explicit opt-out from any layer; always inline.
196
+ * `true` — an explicit FORCE, only from `--fullscreen` / `ARAGON_FULLSCREEN=1`;
197
+ * overrides the TERM/CI/size heuristics.
198
+ * `undefined` — no explicit choice (including a config file holding the
199
+ * default `true`); full-screen with the heuristics live.
200
+ * Folding the persisted default into `true` would turn every user's config
201
+ * file into a permanent "ignore the CI and TERM=dumb downgrades" switch.
202
+ */
203
+ fullscreen?: boolean;
204
+ /** Replay a plain-text summary on exit (full-screen only — see §4.4). */
205
+ exitTranscript: boolean;
206
+ /** Trailing entries the full-screen viewport renders (I-3). */
207
+ transcriptWindow: number;
208
+ confirmTools: boolean;
209
+ toolTimeoutMs: number;
210
+ idleTimeoutMs: number;
211
+ /** provider -> resolved key (config-file key ?? provider env var). */
212
+ apiKeys: Record<string, string | undefined>;
213
+ recentModels: string[];
214
+ promptHistory: string[];
215
+ density: DensityMode;
216
+ hints: boolean;
217
+ submitCount: number;
218
+ skills: SkillsConfig;
219
+ /** Flag/env-only skill switches for this run (§7.5). Never persisted. */
220
+ skillsRuntime: SkillsRuntimeOptions;
221
+ /** Working directory for tools (default `process.cwd()`, overridable). */
222
+ cwd: string;
223
+ /** Whether ANSI color is enabled. */
224
+ color: boolean;
225
+ /** Detected terminal color depth (0 none · 1 16 · 2 256 · 3 truecolor). */
226
+ colorLevel?: 0 | 1 | 2 | 3;
227
+ /** Detected Unicode support (box/round glyphs & spinners). */
228
+ unicode?: boolean;
229
+ /** One-shot `--api-key` override; applies to the active provider only. */
230
+ apiKeyOverride?: string;
231
+ }
232
+ /** Coerce an arbitrary value into a positive integer, or `undefined`. */
233
+ export declare function coerceMaxTokens(v: unknown): number | undefined;
234
+ /** Coerce a value into a positive integer with a fallback (for timeouts). */
235
+ export declare function coercePositiveInt(v: unknown, fallback: number): number;
236
+ /**
237
+ * Mask a secret for display: keep a short prefix + suffix, redact the middle.
238
+ * A short/empty key is fully redacted so the length is not leaked.
239
+ *
240
+ * The glyphs are parameters with ASCII defaults (spec §4.1 tier B). This module
241
+ * has no terminal capabilities to consult, and the result is rendered in the
242
+ * settings screen — which on a legacy `cmd.exe` used to show two question-mark
243
+ * boxes where the redaction dots belonged. The UI passes its resolved glyphs;
244
+ * every other caller gets output that is safe anywhere.
245
+ */
246
+ export declare function maskSecret(key: string | undefined | null, opts?: {
247
+ maskChar?: string;
248
+ ellipsis?: string;
249
+ }): string;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Config schema — the persisted config-file shape, the in-memory effective
3
+ * config, sane defaults, and small validation/masking helpers.
4
+ *
5
+ * See spec §3.5 (resolution pipeline), §6.1 (config file), and §3.8 (timeout
6
+ * invariant).
7
+ */
8
+ // ---------------------------------------------------------------------------
9
+ // Provider set — only the adapter-backed providers are usable at run time (R5).
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * `ModelRegistry` seeds builtin lists for five providers, but `initProviders()`
13
+ * registers only three adapters. Running a model whose provider has no adapter
14
+ * throws `Unknown LLM provider`, so the CLI only ever offers these three.
15
+ */
16
+ export const ADAPTER_PROVIDERS = ['anthropic', 'openai', 'google'];
17
+ export function isAdapterProvider(id) {
18
+ return ADAPTER_PROVIDERS.includes(id);
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // Thinking levels
22
+ // ---------------------------------------------------------------------------
23
+ export const THINKING_LEVELS = [
24
+ 'off',
25
+ 'minimal',
26
+ 'low',
27
+ 'medium',
28
+ 'high',
29
+ 'xhigh',
30
+ ];
31
+ export function isThinkingLevel(v) {
32
+ return typeof v === 'string' && THINKING_LEVELS.includes(v);
33
+ }
34
+ export function clampThinkingLevel(v, fallback) {
35
+ return isThinkingLevel(v) ? v : fallback;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Theme
39
+ // ---------------------------------------------------------------------------
40
+ export const THEME_NAMES = ['auto', 'warm', 'cool', 'light'];
41
+ /** The v0.3.0 name for what is now `cool`. Accepted forever as an alias. */
42
+ export const LEGACY_DARK_THEME = 'dark';
43
+ export function isThemeName(v) {
44
+ return typeof v === 'string' && THEME_NAMES.includes(v);
45
+ }
46
+ /**
47
+ * THE single gate for every theme write (spec §4.5 / R-4).
48
+ *
49
+ * All three write paths funnel through here — config resolution (`load.ts`),
50
+ * `aragon config set theme`, and the `/theme` slash command — which is why the
51
+ * `dark -> cool` migration lives here and nowhere else. Putting it in a
52
+ * `load.ts` normalization step instead would migrate what users *read* but not
53
+ * what they *write*, producing the worst kind of bug: "the flag works but the
54
+ * setting won't stick."
55
+ *
56
+ * An unrecognized value still falls back silently (plus a warn toast at the call
57
+ * site). Turning that into a hard exit would be an unannounced breaking change
58
+ * and would not match `clampThinkingLevel` / `clampTranscriptWindow`.
59
+ */
60
+ export function clampTheme(v, fallback) {
61
+ if (v === LEGACY_DARK_THEME)
62
+ return 'cool';
63
+ return isThemeName(v) ? v : fallback;
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Density / hints (v0.4.0)
67
+ // ---------------------------------------------------------------------------
68
+ export const DENSITY_MODES = ['comfortable', 'compact'];
69
+ export function clampDensity(v, fallback) {
70
+ return v === 'comfortable' || v === 'compact' ? v : fallback;
71
+ }
72
+ /**
73
+ * Submissions after which the composer hint collapses to `? help` (§4.6).
74
+ * Only the IDLE hint fades; the running hint carries the abort key and is
75
+ * always shown in full.
76
+ */
77
+ export const HINT_FADE_AFTER = 8;
78
+ // ---------------------------------------------------------------------------
79
+ // Defaults
80
+ // ---------------------------------------------------------------------------
81
+ export const CONFIG_VERSION = 1;
82
+ export const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
83
+ /** Per-tool executor ceiling (§3.8). Headroom for typical installs/builds. */
84
+ export const DEFAULT_TOOL_TIMEOUT_MS = 180_000;
85
+ /** Extra margin so idle timeout is strictly greater than the tool ceiling. */
86
+ export const IDLE_TIMEOUT_MARGIN_MS = 30_000;
87
+ /** Watchdog idle timeout (§3.8) — must stay ≥ toolTimeout so long tools survive. */
88
+ export const DEFAULT_IDLE_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS + IDLE_TIMEOUT_MARGIN_MS;
89
+ /** Cap on persisted prompt history / recent-model MRU lists. */
90
+ export const PROMPT_HISTORY_CAP = 100;
91
+ export const RECENT_MODELS_CAP = 10;
92
+ /**
93
+ * How many trailing entries the full-screen viewport renders (invariant I-3).
94
+ * Yoga lays out every child even when `overflow: hidden` clips it, so the cost
95
+ * of a long session is linear in the entry count regardless of visibility. The
96
+ * reducer and `/save` keep the full history — this is a render-layer bound only.
97
+ */
98
+ export const DEFAULT_TRANSCRIPT_WINDOW = 300;
99
+ export const MIN_TRANSCRIPT_WINDOW = 50;
100
+ export const MAX_TRANSCRIPT_WINDOW = 2000;
101
+ export function clampTranscriptWindow(v, fallback) {
102
+ const n = coercePositiveInt(v, fallback);
103
+ return Math.min(MAX_TRANSCRIPT_WINDOW, Math.max(MIN_TRANSCRIPT_WINDOW, n));
104
+ }
105
+ export const SKILLS_INTEGRITY_MODES = ['off', 'warn', 'strict'];
106
+ export function clampSkillsIntegrity(v, fallback) {
107
+ return typeof v === 'string' && SKILLS_INTEGRITY_MODES.includes(v)
108
+ ? v
109
+ : fallback;
110
+ }
111
+ export const SKILLS_TOOL_POLICY_MODES = ['off', 'warn', 'enforce'];
112
+ export function isSkillsToolPolicyMode(v) {
113
+ return typeof v === 'string' && SKILLS_TOOL_POLICY_MODES.includes(v);
114
+ }
115
+ export function clampSkillsToolPolicy(v, fallback) {
116
+ return isSkillsToolPolicyMode(v) ? v : fallback;
117
+ }
118
+ export const DEFAULT_ALLOWED_SKILL_HOSTS = [
119
+ 'github.com',
120
+ 'raw.githubusercontent.com',
121
+ 'codeload.github.com',
122
+ 'gitlab.com',
123
+ 'objects.githubusercontent.com',
124
+ ];
125
+ export const DEFAULT_SKILLS_CONFIG = {
126
+ enabled: true,
127
+ disabled: [],
128
+ requireApproval: true,
129
+ allowedHosts: DEFAULT_ALLOWED_SKILL_HOSTS,
130
+ projectDirs: ['.aragon/skills', '.claude/skills'],
131
+ trustedProjectDirs: [],
132
+ catalogMaxBytes: 6000,
133
+ bodyMaxBytes: 30_000,
134
+ integrity: 'warn',
135
+ usageTracking: true,
136
+ toolPolicy: 'enforce',
137
+ };
138
+ const CATALOG_BYTES_RANGE = { min: 500, max: 40_000 };
139
+ /**
140
+ * Upper bound is 50 000, NOT the 90 000 an earlier draft used.
141
+ * `SKILL_RESULT_MAX_BYTES` is 60 000 and must also fit the file list and the
142
+ * head/tail guidance; 50 000 leaves that margin. A 90 000-CHARACTER body is
143
+ * 270 000 bytes of CJK, which `ToolExecutor` would chop mid-tag (D19).
144
+ */
145
+ const BODY_BYTES_RANGE = { min: 1000, max: 50_000 };
146
+ function clampInt(v, fallback, range) {
147
+ const n = coercePositiveInt(v, fallback);
148
+ return Math.min(range.max, Math.max(range.min, n));
149
+ }
150
+ function stringArray(v, fallback) {
151
+ if (!Array.isArray(v))
152
+ return fallback;
153
+ const out = v.filter((item) => typeof item === 'string' && item.trim().length > 0);
154
+ return out.map((s) => s.trim());
155
+ }
156
+ function bool(v, fallback) {
157
+ return typeof v === 'boolean' ? v : fallback;
158
+ }
159
+ /**
160
+ * THE single gate for every skills-config read AND write (§10.3.1).
161
+ *
162
+ * Shared by `loadConfig()`, `loadPersistedConfig()` and `updatePersistedConfig()`
163
+ * on purpose: hardening only the read path would leave a corrupt value on disk
164
+ * that silently reverts to a default on every launch — the failure looks like
165
+ * "my setting won't stick" with no error anywhere.
166
+ */
167
+ export function clampSkillsConfig(raw) {
168
+ const src = (raw && typeof raw === 'object' ? raw : {});
169
+ return {
170
+ enabled: bool(src.enabled, DEFAULT_SKILLS_CONFIG.enabled),
171
+ disabled: stringArray(src.disabled, DEFAULT_SKILLS_CONFIG.disabled),
172
+ requireApproval: bool(src.requireApproval, DEFAULT_SKILLS_CONFIG.requireApproval),
173
+ allowedHosts: stringArray(src.allowedHosts, DEFAULT_SKILLS_CONFIG.allowedHosts),
174
+ projectDirs: stringArray(src.projectDirs, DEFAULT_SKILLS_CONFIG.projectDirs),
175
+ trustedProjectDirs: stringArray(src.trustedProjectDirs, DEFAULT_SKILLS_CONFIG.trustedProjectDirs),
176
+ catalogMaxBytes: clampInt(src.catalogMaxBytes, DEFAULT_SKILLS_CONFIG.catalogMaxBytes, CATALOG_BYTES_RANGE),
177
+ bodyMaxBytes: clampInt(src.bodyMaxBytes, DEFAULT_SKILLS_CONFIG.bodyMaxBytes, BODY_BYTES_RANGE),
178
+ integrity: clampSkillsIntegrity(src.integrity, DEFAULT_SKILLS_CONFIG.integrity),
179
+ usageTracking: bool(src.usageTracking, DEFAULT_SKILLS_CONFIG.usageTracking),
180
+ toolPolicy: clampSkillsToolPolicy(src.toolPolicy, DEFAULT_SKILLS_CONFIG.toolPolicy),
181
+ };
182
+ }
183
+ export const DEFAULT_SKILLS_RUNTIME = { forcedSkills: [], approveAll: false };
184
+ export const DEFAULT_CONFIG = {
185
+ version: CONFIG_VERSION,
186
+ provider: 'anthropic',
187
+ model: DEFAULT_MODEL,
188
+ baseUrl: null,
189
+ thinkingLevel: 'off',
190
+ maxTokens: null,
191
+ theme: 'auto',
192
+ reducedMotion: false,
193
+ fullscreen: true,
194
+ exitTranscript: true,
195
+ transcriptWindow: DEFAULT_TRANSCRIPT_WINDOW,
196
+ confirmTools: false,
197
+ toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
198
+ idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
199
+ apiKeys: {},
200
+ recentModels: [],
201
+ promptHistory: [],
202
+ density: 'comfortable',
203
+ hints: true,
204
+ submitCount: 0,
205
+ skills: DEFAULT_SKILLS_CONFIG,
206
+ };
207
+ // ---------------------------------------------------------------------------
208
+ // Coercion / validation helpers
209
+ // ---------------------------------------------------------------------------
210
+ /** Coerce an arbitrary value into a positive integer, or `undefined`. */
211
+ export function coerceMaxTokens(v) {
212
+ if (v === null || v === undefined || v === '')
213
+ return undefined;
214
+ const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
215
+ if (!Number.isFinite(n) || n <= 0)
216
+ return undefined;
217
+ return Math.floor(n);
218
+ }
219
+ /** Coerce a value into a positive integer with a fallback (for timeouts). */
220
+ export function coercePositiveInt(v, fallback) {
221
+ if (v === null || v === undefined || v === '')
222
+ return fallback;
223
+ const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
224
+ if (!Number.isFinite(n) || n <= 0)
225
+ return fallback;
226
+ return Math.floor(n);
227
+ }
228
+ /**
229
+ * Mask a secret for display: keep a short prefix + suffix, redact the middle.
230
+ * A short/empty key is fully redacted so the length is not leaked.
231
+ *
232
+ * The glyphs are parameters with ASCII defaults (spec §4.1 tier B). This module
233
+ * has no terminal capabilities to consult, and the result is rendered in the
234
+ * settings screen — which on a legacy `cmd.exe` used to show two question-mark
235
+ * boxes where the redaction dots belonged. The UI passes its resolved glyphs;
236
+ * every other caller gets output that is safe anywhere.
237
+ */
238
+ export function maskSecret(key, opts = {}) {
239
+ const maskChar = opts.maskChar ?? '*';
240
+ const ellipsis = opts.ellipsis ?? '...';
241
+ if (!key)
242
+ return '(not set)';
243
+ const trimmed = key.trim();
244
+ if (trimmed.length <= 8)
245
+ return maskChar.repeat(4);
246
+ return `${trimmed.slice(0, 3)}${ellipsis}${trimmed.slice(-4)}`;
247
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Config store — read/atomic-write the user config JSON and resolve OS-specific
3
+ * paths via `env-paths`. The file may hold secrets, so it is written with
4
+ * `0600` permissions on POSIX (spec §3.5 / §6.1).
5
+ */
6
+ import { type PersistedConfig } from './schema.js';
7
+ export declare function getConfigDir(): string;
8
+ export declare function getConfigPath(): string;
9
+ export declare function getSessionsDir(): string;
10
+ /** User-scope skills root — `aragon skills install`'s default target. */
11
+ export declare function getSkillsDir(): string;
12
+ /**
13
+ * Read the persisted config file. Returns a partial config (only the keys the
14
+ * user actually wrote) or `null` when the file is absent or unparseable.
15
+ */
16
+ export declare function readConfigFile(): Partial<PersistedConfig> | null;
17
+ /**
18
+ * Atomically write the persisted config: write a temp file in the same
19
+ * directory, then rename over the target, then re-apply `0600` on POSIX.
20
+ */
21
+ export declare function writeConfigFile(config: PersistedConfig): void;
22
+ /**
23
+ * Load the persisted config merged over defaults (a fully-populated object).
24
+ * Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
25
+ */
26
+ export declare function loadPersistedConfig(): PersistedConfig;
27
+ /**
28
+ * Read the persisted config, apply a patch, write it back atomically, and
29
+ * return the merged result. This is the single write path used by the settings
30
+ * screen and `aragon config set` so env-provided keys never leak into the file.
31
+ *
32
+ * NESTED OBJECTS MUST BE DEEP-MERGED HERE. The top-level spread is shallow, so
33
+ * a patch of `{ skills: { disabled: ['x'] } }` — which is exactly what
34
+ * `/skills disable x` sends — would otherwise REPLACE the whole `skills`
35
+ * section and take `trustedProjectDirs`, `allowedHosts` and `requireApproval`
36
+ * with it. The user's symptom would be that disabling one skill quietly makes
37
+ * every previously-trusted project folder prompt again, with nothing anywhere
38
+ * explaining why.
39
+ *
40
+ * `SkillsConfig` is therefore capped at ONE level of nesting (scalars and
41
+ * string arrays only). A third level requires replacing this hand-written merge
42
+ * with a real deep-merge utility first, or the same bug returns.
43
+ */
44
+ export declare function updatePersistedConfig(patch: Partial<PersistedConfig>): PersistedConfig;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Config store — read/atomic-write the user config JSON and resolve OS-specific
3
+ * paths via `env-paths`. The file may hold secrets, so it is written with
4
+ * `0600` permissions on POSIX (spec §3.5 / §6.1).
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import process from 'node:process';
9
+ import { appPaths as paths } from './app-paths.js';
10
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampSkillsConfig, } from './schema.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Locations
13
+ // ---------------------------------------------------------------------------
14
+ export function getConfigDir() {
15
+ return paths.config;
16
+ }
17
+ export function getConfigPath() {
18
+ return join(paths.config, 'config.json');
19
+ }
20
+ export function getSessionsDir() {
21
+ return join(paths.data, 'sessions');
22
+ }
23
+ /** User-scope skills root — `aragon skills install`'s default target. */
24
+ export function getSkillsDir() {
25
+ return join(paths.data, 'skills');
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Read
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Read the persisted config file. Returns a partial config (only the keys the
32
+ * user actually wrote) or `null` when the file is absent or unparseable.
33
+ */
34
+ export function readConfigFile() {
35
+ const file = getConfigPath();
36
+ if (!existsSync(file))
37
+ return null;
38
+ try {
39
+ const raw = readFileSync(file, 'utf-8');
40
+ const parsed = JSON.parse(raw);
41
+ if (parsed && typeof parsed === 'object') {
42
+ return parsed;
43
+ }
44
+ return null;
45
+ }
46
+ catch {
47
+ // A corrupt config file must never crash the CLI — fall back to defaults.
48
+ return null;
49
+ }
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Write (atomic + 0600)
53
+ // ---------------------------------------------------------------------------
54
+ function ensureDir(dir) {
55
+ if (!existsSync(dir)) {
56
+ mkdirSync(dir, { recursive: true });
57
+ }
58
+ }
59
+ /**
60
+ * Atomically write the persisted config: write a temp file in the same
61
+ * directory, then rename over the target, then re-apply `0600` on POSIX.
62
+ */
63
+ export function writeConfigFile(config) {
64
+ const file = getConfigPath();
65
+ ensureDir(dirname(file));
66
+ const tmp = `${file}.${process.pid}.tmp`;
67
+ const json = `${JSON.stringify(config, null, 2)}\n`;
68
+ writeFileSync(tmp, json, { encoding: 'utf-8', mode: 0o600 });
69
+ renameSync(tmp, file);
70
+ // rename may not preserve mode on every platform; re-chmod on POSIX.
71
+ if (process.platform !== 'win32') {
72
+ try {
73
+ chmodSync(file, 0o600);
74
+ }
75
+ catch {
76
+ // Non-fatal: permission tightening is best-effort.
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Load the persisted config merged over defaults (a fully-populated object).
82
+ * Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
83
+ */
84
+ export function loadPersistedConfig() {
85
+ const partial = readConfigFile() ?? {};
86
+ return {
87
+ ...DEFAULT_CONFIG,
88
+ ...partial,
89
+ version: CONFIG_VERSION,
90
+ apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(partial.apiKeys ?? {}) },
91
+ recentModels: partial.recentModels ?? [],
92
+ promptHistory: partial.promptHistory ?? [],
93
+ // `skills` is the second nested object in this file and needs the same
94
+ // treatment `apiKeys` gets — see the note on updatePersistedConfig below.
95
+ skills: clampSkillsConfig({ ...DEFAULT_CONFIG.skills, ...(partial.skills ?? {}) }),
96
+ };
97
+ }
98
+ /**
99
+ * Read the persisted config, apply a patch, write it back atomically, and
100
+ * return the merged result. This is the single write path used by the settings
101
+ * screen and `aragon config set` so env-provided keys never leak into the file.
102
+ *
103
+ * NESTED OBJECTS MUST BE DEEP-MERGED HERE. The top-level spread is shallow, so
104
+ * a patch of `{ skills: { disabled: ['x'] } }` — which is exactly what
105
+ * `/skills disable x` sends — would otherwise REPLACE the whole `skills`
106
+ * section and take `trustedProjectDirs`, `allowedHosts` and `requireApproval`
107
+ * with it. The user's symptom would be that disabling one skill quietly makes
108
+ * every previously-trusted project folder prompt again, with nothing anywhere
109
+ * explaining why.
110
+ *
111
+ * `SkillsConfig` is therefore capped at ONE level of nesting (scalars and
112
+ * string arrays only). A third level requires replacing this hand-written merge
113
+ * with a real deep-merge utility first, or the same bug returns.
114
+ */
115
+ export function updatePersistedConfig(patch) {
116
+ const current = loadPersistedConfig();
117
+ const merged = {
118
+ ...current,
119
+ ...patch,
120
+ version: CONFIG_VERSION,
121
+ apiKeys: { ...current.apiKeys, ...(patch.apiKeys ?? {}) },
122
+ skills: clampSkillsConfig({ ...current.skills, ...(patch.skills ?? {}) }),
123
+ };
124
+ writeConfigFile(merged);
125
+ return merged;
126
+ }