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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- 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
|
+
}
|