ashlrcode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Feature flags — compile-time dead code elimination via Bun.
3
+ *
4
+ * Usage: if (feature("VOICE_MODE")) { ... }
5
+ * Disabled features are stripped from the bundle at build time.
6
+ */
7
+
8
+ // Feature flag definitions with defaults
9
+ const FLAGS: Record<string, boolean> = {
10
+ VOICE_MODE: false,
11
+ KAIROS: false,
12
+ BROWSER_TOOL: false,
13
+ LSP: false,
14
+ SPECULATION: false,
15
+ DREAM_TASK: true,
16
+ TEAM_MODE: true,
17
+ WORKTREE_AGENTS: true,
18
+ ADVANCED_PERMISSIONS: true,
19
+ EFFORT_LEVELS: true,
20
+ };
21
+
22
+ // Runtime overrides from env vars: AC_FEATURE_VOICE_MODE=true
23
+ for (const [key] of Object.entries(FLAGS)) {
24
+ const envKey = `AC_FEATURE_${key}`;
25
+ const envVal = process.env[envKey];
26
+ if (envVal !== undefined) {
27
+ FLAGS[key] = envVal === "true" || envVal === "1";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Check if a feature is enabled.
33
+ * In production builds, Bun can DCE branches where this returns false.
34
+ */
35
+ export function feature(name: string): boolean {
36
+ return FLAGS[name] ?? false;
37
+ }
38
+
39
+ /**
40
+ * List all feature flags and their current state.
41
+ */
42
+ export function listFeatures(): Record<string, boolean> {
43
+ return { ...FLAGS };
44
+ }
45
+
46
+ /**
47
+ * Enable/disable a feature at runtime (for testing/debugging).
48
+ */
49
+ export function setFeature(name: string, enabled: boolean): void {
50
+ FLAGS[name] = enabled;
51
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Git utilities — repo analysis, VCS detection, and system prompt enrichment.
3
+ */
4
+
5
+ import { existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { createHash } from "crypto";
8
+
9
+ export interface GitContext {
10
+ isRepo: boolean;
11
+ branch?: string;
12
+ status?: string;
13
+ remoteUrl?: string;
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Core helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ async function runGit(cwd: string, args: string): Promise<string | null> {
21
+ try {
22
+ const proc = Bun.spawn(["git", ...args.split(" ")], {
23
+ cwd,
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ });
27
+ const stdout = await new Response(proc.stdout).text();
28
+ const exitCode = await proc.exited;
29
+ return exitCode === 0 ? stdout.trim() : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // VCS detection
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Detect the VCS type of a directory. */
40
+ export async function detectVCS(cwd: string): Promise<"git" | "svn" | "hg" | "none"> {
41
+ if (existsSync(join(cwd, ".git"))) return "git";
42
+ if (existsSync(join(cwd, ".svn"))) return "svn";
43
+ if (existsSync(join(cwd, ".hg"))) return "hg";
44
+ return "none";
45
+ }
46
+
47
+ /** Check if directory is inside a git repo (works for nested dirs). */
48
+ export async function isGitRepo(cwd: string): Promise<boolean> {
49
+ const result = await runGit(cwd, "rev-parse --is-inside-work-tree");
50
+ return result === "true";
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Branch / remote
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Get current git branch. */
58
+ export async function getCurrentBranch(cwd: string): Promise<string | null> {
59
+ return runGit(cwd, "rev-parse --abbrev-ref HEAD");
60
+ }
61
+
62
+ /** Get git remote URL (for repo identification). */
63
+ export async function getRemoteUrl(cwd: string): Promise<string | null> {
64
+ return runGit(cwd, "remote get-url origin");
65
+ }
66
+
67
+ /** Get a short hash of the remote URL (for session association). */
68
+ export async function getRepoHash(cwd: string): Promise<string | null> {
69
+ const url = await getRemoteUrl(cwd);
70
+ if (!url) return null;
71
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Status / history
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** Get recent commits summary. */
79
+ export async function getRecentCommits(cwd: string, count: number = 5): Promise<string[]> {
80
+ const output = await runGit(cwd, `log --oneline -${count}`);
81
+ return output ? output.split("\n") : [];
82
+ }
83
+
84
+ /** Get git status summary (counts). */
85
+ export async function getGitStatus(cwd: string): Promise<{ modified: number; untracked: number; staged: number }> {
86
+ const output = await runGit(cwd, "status --porcelain");
87
+ if (!output) return { modified: 0, untracked: 0, staged: 0 };
88
+ const lines = output.split("\n").filter(Boolean);
89
+ return {
90
+ modified: lines.filter(l => l.startsWith(" M") || l.startsWith("M ")).length,
91
+ untracked: lines.filter(l => l.startsWith("??")).length,
92
+ staged: lines.filter(l => l.startsWith("A ") || l.startsWith("M ")).length,
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Legacy aggregate context (used by cli.ts today)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Detect git repo context for the current working directory.
102
+ */
103
+ export async function getGitContext(cwd: string): Promise<GitContext> {
104
+ if (!existsSync(join(cwd, ".git"))) {
105
+ return { isRepo: false };
106
+ }
107
+
108
+ const branch = await runGit(cwd, "rev-parse --abbrev-ref HEAD");
109
+ const status = await runGit(cwd, "status --porcelain");
110
+ const remoteUrl = await runGit(cwd, "config --get remote.origin.url");
111
+
112
+ return {
113
+ isRepo: true,
114
+ branch: branch ?? undefined,
115
+ status: status ?? undefined,
116
+ remoteUrl: remoteUrl ?? undefined,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Format git context for inclusion in system prompt.
122
+ */
123
+ export function formatGitPrompt(ctx: GitContext): string {
124
+ if (!ctx.isRepo) return "";
125
+
126
+ const lines = ["# Git Context"];
127
+ if (ctx.branch) lines.push(`- Branch: ${ctx.branch}`);
128
+ if (ctx.remoteUrl) lines.push(`- Remote: ${ctx.remoteUrl}`);
129
+ if (ctx.status) {
130
+ const changes = ctx.status.split("\n").filter(Boolean).length;
131
+ lines.push(`- ${changes} uncommitted change(s)`);
132
+ } else {
133
+ lines.push("- Clean working tree");
134
+ }
135
+
136
+ return lines.join("\n");
137
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Hook system — pre/post tool execution hooks from settings.json.
3
+ *
4
+ * Hooks can approve, deny, or modify tool calls via shell commands.
5
+ */
6
+
7
+ export interface HookDefinition {
8
+ /** Match by tool name (exact or glob pattern) */
9
+ toolName?: string;
10
+ /** Match by input pattern (regex against JSON-serialized input) */
11
+ inputPattern?: string;
12
+ /** Shell command to execute (has access to env vars: TOOL_NAME, TOOL_INPUT) */
13
+ command?: string;
14
+ /** Direct action without running a command */
15
+ action?: "allow" | "deny";
16
+ /** Message to show when action is deny */
17
+ message?: string;
18
+ }
19
+
20
+ export interface HooksConfig {
21
+ preToolUse?: HookDefinition[];
22
+ postToolUse?: HookDefinition[];
23
+ }
24
+
25
+ export interface PreHookResult {
26
+ action: "allow" | "deny";
27
+ message?: string;
28
+ }
29
+
30
+ /**
31
+ * Run pre-tool-use hooks. Returns deny if any hook denies.
32
+ */
33
+ export async function runPreToolHooks(
34
+ hooks: HooksConfig,
35
+ toolName: string,
36
+ input: Record<string, unknown>
37
+ ): Promise<PreHookResult> {
38
+ const preHooks = hooks.preToolUse ?? [];
39
+
40
+ for (const hook of preHooks) {
41
+ if (!matchesHook(hook, toolName, input)) continue;
42
+
43
+ // Direct action (no command needed)
44
+ if (hook.action === "deny") {
45
+ return { action: "deny", message: hook.message ?? `Denied by hook for ${toolName}` };
46
+ }
47
+ if (hook.action === "allow") {
48
+ return { action: "allow" };
49
+ }
50
+
51
+ // Run shell command
52
+ if (hook.command) {
53
+ const result = await runHookCommand(hook.command, toolName, input);
54
+ if (result.exitCode !== 0) {
55
+ return {
56
+ action: "deny",
57
+ message: result.output || `Hook command failed for ${toolName}`,
58
+ };
59
+ }
60
+ }
61
+ }
62
+
63
+ return { action: "allow" };
64
+ }
65
+
66
+ /**
67
+ * Run post-tool-use hooks. Fire-and-forget.
68
+ */
69
+ export async function runPostToolHooks(
70
+ hooks: HooksConfig,
71
+ toolName: string,
72
+ input: Record<string, unknown>,
73
+ result: string
74
+ ): Promise<void> {
75
+ const postHooks = hooks.postToolUse ?? [];
76
+
77
+ for (const hook of postHooks) {
78
+ if (!matchesHook(hook, toolName, input)) continue;
79
+
80
+ if (hook.command) {
81
+ // Fire and forget
82
+ runHookCommand(hook.command, toolName, input, result).catch(() => {});
83
+ }
84
+ }
85
+ }
86
+
87
+ function matchesHook(
88
+ hook: HookDefinition,
89
+ toolName: string,
90
+ input: Record<string, unknown>
91
+ ): boolean {
92
+ // Match tool name
93
+ if (hook.toolName) {
94
+ if (hook.toolName.includes("*")) {
95
+ try {
96
+ // Escape regex metacharacters, then expand * to .*
97
+ const escaped = hook.toolName
98
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
99
+ .replace(/\*/g, ".*");
100
+ const regex = new RegExp("^" + escaped + "$");
101
+ if (!regex.test(toolName)) return false;
102
+ } catch {
103
+ // Invalid pattern, fall back to exact match
104
+ if (hook.toolName !== toolName) return false;
105
+ }
106
+ } else if (hook.toolName !== toolName) {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ // Match input pattern (with length guard against ReDoS)
112
+ if (hook.inputPattern) {
113
+ const inputStr = JSON.stringify(input);
114
+ // Guard against catastrophic backtracking on large inputs
115
+ if (inputStr.length > 10_000) {
116
+ // For very large inputs, use simple string.includes as fallback
117
+ if (!inputStr.includes(hook.inputPattern)) return false;
118
+ } else {
119
+ try {
120
+ const regex = new RegExp(hook.inputPattern);
121
+ if (!regex.test(inputStr)) return false;
122
+ } catch {
123
+ return false; // Invalid regex, skip this hook
124
+ }
125
+ }
126
+ }
127
+
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Convert settings.json toolHooks format into internal HooksConfig.
133
+ * This bridges the user-facing config shape (tool/inputPattern/command/action)
134
+ * to the internal HookDefinition shape (toolName/inputPattern/command/action/message).
135
+ */
136
+ export function loadHooksFromSettings(toolHooks: {
137
+ preToolUse?: Array<{
138
+ tool?: string;
139
+ inputPattern?: string;
140
+ command?: string;
141
+ action?: "allow" | "deny";
142
+ }>;
143
+ postToolUse?: Array<{
144
+ tool?: string;
145
+ command?: string;
146
+ }>;
147
+ }): HooksConfig {
148
+ const config: HooksConfig = {};
149
+
150
+ if (toolHooks.preToolUse) {
151
+ config.preToolUse = toolHooks.preToolUse.map((rule) => ({
152
+ toolName: rule.tool,
153
+ inputPattern: rule.inputPattern,
154
+ command: rule.command,
155
+ action: rule.action,
156
+ message: rule.action === "deny" ? `Denied by toolHooks rule for ${rule.tool ?? "*"}` : undefined,
157
+ }));
158
+ }
159
+
160
+ if (toolHooks.postToolUse) {
161
+ config.postToolUse = toolHooks.postToolUse.map((rule) => ({
162
+ toolName: rule.tool,
163
+ command: rule.command,
164
+ }));
165
+ }
166
+
167
+ return config;
168
+ }
169
+
170
+ async function runHookCommand(
171
+ command: string,
172
+ toolName: string,
173
+ input: Record<string, unknown>,
174
+ result?: string
175
+ ): Promise<{ exitCode: number; output: string }> {
176
+ const env = {
177
+ ...process.env,
178
+ TOOL_NAME: toolName,
179
+ TOOL_INPUT: JSON.stringify(input),
180
+ ...(result ? { TOOL_RESULT: result.slice(0, 10_000) } : {}),
181
+ };
182
+
183
+ const proc = Bun.spawn(["bash", "-c", command], {
184
+ stdout: "pipe",
185
+ stderr: "pipe",
186
+ env,
187
+ });
188
+
189
+ // Timeout hook commands at 15 seconds
190
+ const timeoutId = setTimeout(() => proc.kill(), 15_000);
191
+
192
+ try {
193
+ const stdout = await new Response(proc.stdout).text();
194
+ const exitCode = await proc.exited;
195
+ clearTimeout(timeoutId);
196
+ return { exitCode, output: stdout.trim() };
197
+ } catch {
198
+ clearTimeout(timeoutId);
199
+ return { exitCode: 1, output: "Hook command timed out" };
200
+ }
201
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Permission system — configurable allow/deny/ask rules with persistence.
3
+ *
4
+ * Permissions are saved to ~/.ashlrcode/permissions.json and persist
5
+ * across sessions. Users can choose:
6
+ * y = allow once
7
+ * a = always allow (persisted)
8
+ * n = deny once
9
+ * d = always deny (persisted)
10
+ */
11
+
12
+ import { existsSync } from "fs";
13
+ import { readFile, writeFile, mkdir } from "fs/promises";
14
+ import { join } from "path";
15
+ import { getConfigDir } from "./settings.ts";
16
+
17
+ export interface PermissionState {
18
+ alwaysAllow: Set<string>;
19
+ alwaysDeny: Set<string>;
20
+ /** Session-only allows (not persisted) */
21
+ sessionAllow: Set<string>;
22
+ }
23
+
24
+ interface PersistedPermissions {
25
+ alwaysAllow: string[];
26
+ alwaysDeny: string[];
27
+ }
28
+
29
+ function getPermissionsPath(): string {
30
+ return join(getConfigDir(), "permissions.json");
31
+ }
32
+
33
+ // Default read-only tools that never need permission
34
+ const READ_ONLY_AUTO_ALLOW = new Set([
35
+ "Read", "Glob", "Grep", "AskUser", "WebFetch",
36
+ "EnterPlan", "ExitPlan", "PlanWrite",
37
+ "TaskCreate", "TaskUpdate", "TaskList",
38
+ "Agent",
39
+ ]);
40
+
41
+ let state: PermissionState = {
42
+ alwaysAllow: new Set(),
43
+ alwaysDeny: new Set(),
44
+ sessionAllow: new Set(),
45
+ };
46
+
47
+ /** Bypass mode — when true, all permissions are auto-approved */
48
+ let bypassMode = false;
49
+
50
+ /** Auto-accept edits — when true, Write/Edit are auto-approved but Bash still asks */
51
+ let autoAcceptEdits = false;
52
+
53
+ export function setBypassMode(enabled: boolean): void {
54
+ bypassMode = enabled;
55
+ }
56
+
57
+ export function setAutoAcceptEdits(enabled: boolean): void {
58
+ autoAcceptEdits = enabled;
59
+ }
60
+
61
+ export function isBypassMode(): boolean {
62
+ return bypassMode;
63
+ }
64
+
65
+ export async function loadPermissions(): Promise<void> {
66
+ const permissionsPath = getPermissionsPath();
67
+ if (!existsSync(permissionsPath)) return;
68
+
69
+ try {
70
+ const raw = await readFile(permissionsPath, "utf-8");
71
+ const data = JSON.parse(raw) as PersistedPermissions;
72
+ state.alwaysAllow = new Set(data.alwaysAllow ?? []);
73
+ state.alwaysDeny = new Set(data.alwaysDeny ?? []);
74
+ } catch {
75
+ // Corrupted file, start fresh
76
+ }
77
+ }
78
+
79
+ async function savePermissions(): Promise<void> {
80
+ await mkdir(getConfigDir(), { recursive: true });
81
+ const data: PersistedPermissions = {
82
+ alwaysAllow: Array.from(state.alwaysAllow),
83
+ alwaysDeny: Array.from(state.alwaysDeny),
84
+ };
85
+ await writeFile(getPermissionsPath(), JSON.stringify(data, null, 2), "utf-8");
86
+ }
87
+
88
+ /**
89
+ * Check if a tool needs user permission.
90
+ * Returns: "allow" (auto-approved), "deny" (auto-blocked), "ask" (prompt user)
91
+ */
92
+ export function checkPermission(toolName: string): "allow" | "deny" | "ask" {
93
+ // Bypass mode — approve everything
94
+ if (bypassMode) return "allow";
95
+
96
+ // Read-only tools are always allowed
97
+ if (READ_ONLY_AUTO_ALLOW.has(toolName)) return "allow";
98
+
99
+ // Auto-accept edits mode — approve Write/Edit but still ask for Bash
100
+ if (autoAcceptEdits && (toolName === "Write" || toolName === "Edit")) return "allow";
101
+
102
+ // Check persistent deny
103
+ if (state.alwaysDeny.has(toolName)) return "deny";
104
+
105
+ // Check persistent allow
106
+ if (state.alwaysAllow.has(toolName)) return "allow";
107
+
108
+ // Check session allow
109
+ if (state.sessionAllow.has(toolName)) return "allow";
110
+
111
+ return "ask";
112
+ }
113
+
114
+ /**
115
+ * Record a permission decision.
116
+ */
117
+ export async function recordPermission(
118
+ toolName: string,
119
+ decision: "allow_once" | "always_allow" | "deny_once" | "always_deny"
120
+ ): Promise<void> {
121
+ switch (decision) {
122
+ case "allow_once":
123
+ // No persistence needed
124
+ break;
125
+ case "always_allow":
126
+ state.alwaysAllow.add(toolName);
127
+ state.alwaysDeny.delete(toolName);
128
+ await savePermissions();
129
+ break;
130
+ case "deny_once":
131
+ // No persistence needed
132
+ break;
133
+ case "always_deny":
134
+ state.alwaysDeny.add(toolName);
135
+ state.alwaysAllow.delete(toolName);
136
+ await savePermissions();
137
+ break;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Allow a tool for this session only (not persisted).
143
+ */
144
+ export function allowForSession(toolName: string): void {
145
+ state.sessionAllow.add(toolName);
146
+ }
147
+
148
+ export function getPermissionState(): PermissionState {
149
+ return state;
150
+ }
151
+
152
+ // --- Ink-mode permission resolver (callback-based, like AskUser) ---
153
+
154
+ let pendingPermissionResolve: ((decision: "allow_once" | "always_allow" | "deny_once" | "always_deny") => void) | null = null;
155
+ let pendingPermissionInfo: { toolName: string; description: string } | null = null;
156
+
157
+ export function hasPendingPermission(): boolean {
158
+ return pendingPermissionResolve !== null;
159
+ }
160
+
161
+ export function getPendingPermissionInfo(): { toolName: string; description: string } | null {
162
+ return pendingPermissionInfo;
163
+ }
164
+
165
+ /**
166
+ * Resolve a pending Ink-mode permission prompt with a single-key answer.
167
+ * Returns true if the key was recognized and the pending prompt was resolved.
168
+ */
169
+ export function answerPendingPermission(key: string): boolean {
170
+ if (!pendingPermissionResolve) return false;
171
+ const decisions: Record<string, "allow_once" | "always_allow" | "deny_once" | "always_deny"> = {
172
+ y: "allow_once",
173
+ a: "always_allow",
174
+ n: "deny_once",
175
+ d: "always_deny",
176
+ };
177
+ const decision = decisions[key.toLowerCase()];
178
+ if (!decision) return false;
179
+
180
+ pendingPermissionResolve(decision);
181
+ pendingPermissionResolve = null;
182
+ pendingPermissionInfo = null;
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * Request permission in Ink mode. Blocks (via Promise) until the user
188
+ * types a recognized key (y/a/n/d) that gets routed through
189
+ * answerPendingPermission().
190
+ */
191
+ export async function requestPermissionInk(toolName: string, description: string): Promise<boolean> {
192
+ pendingPermissionInfo = { toolName, description };
193
+
194
+ const decision = await new Promise<"allow_once" | "always_allow" | "deny_once" | "always_deny">((resolve) => {
195
+ pendingPermissionResolve = resolve;
196
+ });
197
+
198
+ await recordPermission(toolName, decision);
199
+ if (decision === "allow_once") allowForSession(toolName);
200
+
201
+ return decision === "allow_once" || decision === "always_allow";
202
+ }
203
+
204
+ export function resetPermissionsForTests(): void {
205
+ state = {
206
+ alwaysAllow: new Set(),
207
+ alwaysDeny: new Set(),
208
+ sessionAllow: new Set(),
209
+ };
210
+ bypassMode = false;
211
+ autoAcceptEdits = false;
212
+ rules = [];
213
+ }
214
+
215
+ // --- Input-based permission rules ---
216
+
217
+ export interface PermissionRule {
218
+ tool: string; // Exact name or simple glob ("File*", "*Bash")
219
+ inputPattern?: string; // Regex to match against JSON-stringified input
220
+ action: "allow" | "deny" | "ask";
221
+ }
222
+
223
+ let rules: PermissionRule[] = [];
224
+
225
+ function matchesToolPattern(pattern: string, toolName: string): boolean {
226
+ if (pattern === "*") return true;
227
+ if (pattern === toolName) return true;
228
+ if (pattern.startsWith("*") && toolName.endsWith(pattern.slice(1))) return true;
229
+ if (pattern.endsWith("*") && toolName.startsWith(pattern.slice(0, -1))) return true;
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * Check input-based permission rules. Returns the action of the first matching rule,
235
+ * or null if no rule matches.
236
+ */
237
+ export function checkRules(toolName: string, input?: Record<string, unknown>): "allow" | "deny" | "ask" | null {
238
+ const inputStr = input ? JSON.stringify(input) : "";
239
+ for (const rule of rules) {
240
+ if (!matchesToolPattern(rule.tool, toolName)) continue;
241
+ if (rule.inputPattern) {
242
+ try { if (!new RegExp(rule.inputPattern).test(inputStr)) continue; }
243
+ catch { continue; }
244
+ }
245
+ return rule.action;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ export function setRules(newRules: PermissionRule[]): void { rules = newRules; }
251
+ export function getRules(): PermissionRule[] { return rules; }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Project configuration — loads ASHLR.md / CLAUDE.md from project directories.
3
+ *
4
+ * Walks up the directory tree, merging project configs found along the way
5
+ * (closest takes precedence, similar to .gitignore behavior).
6
+ */
7
+
8
+ import { existsSync } from "fs";
9
+ import { readFile } from "fs/promises";
10
+ import { join, dirname, resolve } from "path";
11
+ import { getConfigDir } from "./settings.ts";
12
+
13
+ const CONFIG_FILENAMES = ["ASHLR.md", "CLAUDE.md"];
14
+
15
+ export interface ProjectConfig {
16
+ instructions: string;
17
+ sources: string[]; // file paths where instructions were found
18
+ }
19
+
20
+ /**
21
+ * Load project configuration by walking up from cwd.
22
+ */
23
+ export async function loadProjectConfig(cwd: string): Promise<ProjectConfig> {
24
+ const discovered: Array<{ path: string; content: string }> = [];
25
+ const seen = new Set<string>();
26
+
27
+ let dir = resolve(cwd);
28
+
29
+ // Walk up, collect configs (stop at filesystem root or after 10 levels)
30
+ for (let i = 0; i < 10; i++) {
31
+ for (const filename of CONFIG_FILENAMES) {
32
+ const configPath = join(dir, filename);
33
+ if (existsSync(configPath) && !seen.has(configPath)) {
34
+ seen.add(configPath);
35
+ const content = await readFile(configPath, "utf-8");
36
+ discovered.push({ path: configPath, content: `# ${filename} (${dir})\n\n${content}` });
37
+ }
38
+ }
39
+ const parentDir = dirname(dir);
40
+ if (parentDir === dir) break;
41
+ dir = parentDir;
42
+ }
43
+
44
+ // Also check home directory for global config
45
+ const homeConfig = join(getConfigDir(), "ASHLR.md");
46
+ if (existsSync(homeConfig) && !seen.has(homeConfig)) {
47
+ const content = await readFile(homeConfig, "utf-8");
48
+ discovered.push({ path: homeConfig, content: `# Global ASHLR.md\n\n${content}` });
49
+ }
50
+
51
+ const homePrefix = `${getConfigDir()}/`;
52
+ const ordered = discovered.sort((a, b) => {
53
+ const depth = (path: string) => path.split("/").length;
54
+ const aDepth = a.path.startsWith(homePrefix) ? -1 : depth(a.path);
55
+ const bDepth = b.path.startsWith(homePrefix) ? -1 : depth(b.path);
56
+ return aDepth - bDepth;
57
+ });
58
+
59
+ return {
60
+ instructions: ordered.map((entry) => entry.content).join("\n\n---\n\n"),
61
+ sources: ordered.map((entry) => entry.path),
62
+ };
63
+ }