@panchr/tyr 0.1.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.
@@ -0,0 +1,154 @@
1
+ import { defineCommand } from "citty";
2
+ import { parseTime, rejectUnknownArgs } from "../args.ts";
3
+ import { closeDb, getDb } from "../db.ts";
4
+
5
+ const statsArgs = {
6
+ since: {
7
+ type: "string" as const,
8
+ description:
9
+ "Show stats for entries after timestamp (ISO or relative: 1h, 30m, 7d)",
10
+ },
11
+ json: {
12
+ type: "boolean" as const,
13
+ description: "Output raw JSON",
14
+ },
15
+ };
16
+
17
+ interface DecisionCount {
18
+ decision: string;
19
+ count: number;
20
+ }
21
+
22
+ interface ProviderCount {
23
+ provider: string | null;
24
+ count: number;
25
+ }
26
+
27
+ export default defineCommand({
28
+ meta: {
29
+ name: "stats",
30
+ description: "Show permission check statistics",
31
+ },
32
+ args: statsArgs,
33
+ async run({ args, rawArgs }) {
34
+ rejectUnknownArgs(rawArgs, statsArgs);
35
+
36
+ let since: number | undefined;
37
+ if (args.since) {
38
+ const t = parseTime(args.since);
39
+ if (!t) {
40
+ console.error(`Invalid --since value: ${args.since}`);
41
+ process.exit(1);
42
+ return;
43
+ }
44
+ since = t.getTime();
45
+ }
46
+
47
+ const db = getDb();
48
+ const conditions: string[] = [];
49
+ const params: number[] = [];
50
+
51
+ if (since !== undefined) {
52
+ conditions.push("timestamp >= ?");
53
+ params.push(since);
54
+ }
55
+
56
+ const whereClause =
57
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
58
+
59
+ // Total count
60
+ const totalRow = db
61
+ .query(`SELECT COUNT(*) as count FROM logs ${whereClause}`)
62
+ .get(...params) as { count: number };
63
+ const total = totalRow.count;
64
+
65
+ // Decisions by type
66
+ const decisionRows = db
67
+ .query(
68
+ `SELECT decision, COUNT(*) as count FROM logs ${whereClause} GROUP BY decision ORDER BY count DESC`,
69
+ )
70
+ .all(...params) as DecisionCount[];
71
+
72
+ // Cache hit rate
73
+ const cacheRow = db
74
+ .query(
75
+ `SELECT SUM(cached) as hits, COUNT(*) as total FROM logs ${whereClause}`,
76
+ )
77
+ .get(...params) as { hits: number | null; total: number };
78
+ const cacheHits = cacheRow.hits ?? 0;
79
+ const cacheRate =
80
+ cacheRow.total > 0 ? (cacheHits / cacheRow.total) * 100 : 0;
81
+
82
+ // Provider breakdown
83
+ const providerRows = db
84
+ .query(
85
+ `SELECT provider, COUNT(*) as count FROM logs ${whereClause} GROUP BY provider ORDER BY count DESC`,
86
+ )
87
+ .all(...params) as ProviderCount[];
88
+
89
+ // Auto-approvals (allow decisions = effort saved)
90
+ const allowConditions = [...conditions, "decision = 'allow'"];
91
+ const allowWhere = `WHERE ${allowConditions.join(" AND ")}`;
92
+ const allowRow = db
93
+ .query(`SELECT COUNT(*) as count FROM logs ${allowWhere}`)
94
+ .get(...params) as { count: number };
95
+
96
+ const stats = {
97
+ total,
98
+ decisions: Object.fromEntries(
99
+ decisionRows.map((r) => [r.decision, r.count]),
100
+ ),
101
+ cache: {
102
+ hits: cacheHits,
103
+ rate: Math.round(cacheRate * 10) / 10,
104
+ },
105
+ providers: Object.fromEntries(
106
+ providerRows.map((r) => [r.provider ?? "none", r.count]),
107
+ ),
108
+ autoApprovals: allowRow.count,
109
+ };
110
+
111
+ if (args.json) {
112
+ console.log(JSON.stringify(stats));
113
+ closeDb();
114
+ return;
115
+ }
116
+
117
+ // Human-readable output
118
+ console.log("Permission Check Statistics");
119
+ if (args.since) {
120
+ console.log(` Period: since ${args.since}`);
121
+ }
122
+ console.log(` Total checks: ${total}`);
123
+ console.log();
124
+
125
+ console.log("Decisions:");
126
+ for (const type of ["allow", "deny", "abstain", "error"]) {
127
+ const count = stats.decisions[type] ?? 0;
128
+ const pct = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0";
129
+ console.log(
130
+ ` ${type.padEnd(10)} ${String(count).padStart(6)} (${pct}%)`,
131
+ );
132
+ }
133
+ console.log();
134
+
135
+ console.log("Cache:");
136
+ console.log(` Hit rate: ${stats.cache.rate}% (${cacheHits}/${total})`);
137
+ console.log();
138
+
139
+ console.log("Providers:");
140
+ if (providerRows.length === 0) {
141
+ console.log(" (none)");
142
+ } else {
143
+ for (const row of providerRows) {
144
+ const name = row.provider ?? "none";
145
+ console.log(` ${name.padEnd(20)} ${String(row.count).padStart(6)}`);
146
+ }
147
+ }
148
+ console.log();
149
+
150
+ console.log(`Auto-approvals (user effort saved): ${stats.autoApprovals}`);
151
+
152
+ closeDb();
153
+ },
154
+ });
@@ -0,0 +1,184 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { defineCommand } from "citty";
4
+ import {
5
+ extractBashPatterns,
6
+ matchPattern,
7
+ settingsPaths,
8
+ } from "../agents/claude.ts";
9
+ import { rejectUnknownArgs } from "../args.ts";
10
+ import { closeDb, getDb } from "../db.ts";
11
+ import { readSettings, writeSettings } from "../install.ts";
12
+
13
+ const suggestArgs = {
14
+ apply: {
15
+ type: "boolean" as const,
16
+ description: "Write suggestions into Claude's settings.json",
17
+ },
18
+ global: {
19
+ type: "boolean" as const,
20
+ description: "Target global (~/.claude/settings.json)",
21
+ },
22
+ project: {
23
+ type: "boolean" as const,
24
+ description: "Target project (./.claude/settings.json)",
25
+ },
26
+ "min-count": {
27
+ type: "string" as const,
28
+ description: "Minimum approval count to suggest (default: 5)",
29
+ },
30
+ json: {
31
+ type: "boolean" as const,
32
+ description: "Output raw JSON",
33
+ },
34
+ };
35
+
36
+ interface CommandFrequency {
37
+ tool_input: string;
38
+ count: number;
39
+ }
40
+
41
+ export interface Suggestion {
42
+ command: string;
43
+ count: number;
44
+ rule: string;
45
+ }
46
+
47
+ /** Query frequently-allowed commands and filter out those already in allow lists. */
48
+ export function getSuggestions(
49
+ minCount: number,
50
+ allowPatterns: string[],
51
+ ): Suggestion[] {
52
+ const db = getDb();
53
+
54
+ const rows = db
55
+ .query(
56
+ `SELECT tool_input, COUNT(*) as count
57
+ FROM logs
58
+ WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
59
+ GROUP BY tool_input
60
+ HAVING COUNT(*) >= ?
61
+ ORDER BY COUNT(*) DESC`,
62
+ )
63
+ .all(minCount) as CommandFrequency[];
64
+
65
+ const suggestions: Suggestion[] = [];
66
+ for (const row of rows) {
67
+ const alreadyAllowed = allowPatterns.some((p) =>
68
+ matchPattern(p, row.tool_input),
69
+ );
70
+ if (!alreadyAllowed) {
71
+ suggestions.push({
72
+ command: row.tool_input,
73
+ count: row.count,
74
+ rule: `Bash(${row.tool_input})`,
75
+ });
76
+ }
77
+ }
78
+
79
+ return suggestions;
80
+ }
81
+
82
+ /** Merge new allow rules into existing settings without clobbering. */
83
+ export function mergeAllowRules(
84
+ settings: Record<string, unknown>,
85
+ rules: string[],
86
+ ): Record<string, unknown> {
87
+ const result = { ...settings };
88
+ const perms = (result.permissions ?? {}) as Record<string, unknown>;
89
+ const existing = Array.isArray(perms.allow) ? (perms.allow as string[]) : [];
90
+
91
+ const existingSet = new Set(existing);
92
+ const merged = [...existing, ...rules.filter((r) => !existingSet.has(r))];
93
+
94
+ result.permissions = { ...perms, allow: merged };
95
+ return result;
96
+ }
97
+
98
+ export default defineCommand({
99
+ meta: {
100
+ name: "suggest",
101
+ description:
102
+ "Suggest permissions to add to Claude settings based on decision history",
103
+ },
104
+ args: suggestArgs,
105
+ async run({ args, rawArgs }) {
106
+ rejectUnknownArgs(rawArgs, suggestArgs);
107
+
108
+ if (args.global && args.project) {
109
+ console.error("Cannot specify both --global and --project");
110
+ process.exit(1);
111
+ return;
112
+ }
113
+
114
+ const minCount = args["min-count"] ? Number(args["min-count"]) : 5;
115
+ if (!Number.isFinite(minCount) || minCount < 1) {
116
+ console.error(`Invalid --min-count value: ${args["min-count"]}`);
117
+ process.exit(1);
118
+ return;
119
+ }
120
+
121
+ try {
122
+ // Load all allow patterns from Claude settings to filter suggestions
123
+ const allPaths = settingsPaths(process.cwd());
124
+ const allowPatterns: string[] = [];
125
+ for (const path of allPaths) {
126
+ const settings = await readSettings(path);
127
+ const perms = settings.permissions as
128
+ | Record<string, unknown>
129
+ | undefined;
130
+ if (perms && Array.isArray(perms.allow)) {
131
+ allowPatterns.push(...extractBashPatterns(perms.allow));
132
+ }
133
+ }
134
+
135
+ const suggestions = getSuggestions(minCount, allowPatterns);
136
+
137
+ if (args.json) {
138
+ console.log(JSON.stringify(suggestions));
139
+ return;
140
+ }
141
+
142
+ if (suggestions.length === 0) {
143
+ console.log("No new suggestions found.");
144
+ return;
145
+ }
146
+
147
+ if (!args.apply) {
148
+ console.log(
149
+ `Suggested allow rules (commands approved >= ${minCount} times):`,
150
+ );
151
+ console.log();
152
+ for (const s of suggestions) {
153
+ console.log(` ${s.rule} (${s.count} approvals)`);
154
+ }
155
+ console.log();
156
+ console.log("Run with --apply to add these rules to Claude settings.");
157
+ return;
158
+ }
159
+
160
+ // Apply mode: write rules to settings
161
+ const scope: "global" | "project" = args.project ? "project" : "global";
162
+ const configDir =
163
+ process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
164
+ const settingsPath =
165
+ scope === "global"
166
+ ? join(configDir, "settings.json")
167
+ : join(process.cwd(), ".claude", "settings.json");
168
+ const settings = await readSettings(settingsPath);
169
+
170
+ const newRules = suggestions.map((s) => s.rule);
171
+ const merged = mergeAllowRules(settings, newRules);
172
+ await writeSettings(settingsPath, merged);
173
+
174
+ console.log(
175
+ `Added ${newRules.length} allow rule(s) to ${scope} settings (${settingsPath}):`,
176
+ );
177
+ for (const rule of newRules) {
178
+ console.log(` ${rule}`);
179
+ }
180
+ } finally {
181
+ closeDb();
182
+ }
183
+ },
184
+ });
@@ -0,0 +1,54 @@
1
+ import { defineCommand } from "citty";
2
+ import { rejectUnknownArgs } from "../args.ts";
3
+ import {
4
+ getSettingsPath,
5
+ readSettings,
6
+ removeHook,
7
+ writeSettings,
8
+ } from "../install.ts";
9
+
10
+ const uninstallArgs = {
11
+ global: {
12
+ type: "boolean" as const,
13
+ description: "Remove from ~/.claude/settings.json",
14
+ },
15
+ project: {
16
+ type: "boolean" as const,
17
+ description: "Remove from .claude/settings.json (default)",
18
+ },
19
+ "dry-run": {
20
+ type: "boolean" as const,
21
+ description: "Print what would be written without modifying anything",
22
+ },
23
+ };
24
+
25
+ export default defineCommand({
26
+ meta: {
27
+ name: "uninstall",
28
+ description: "Remove the tyr hook from Claude Code settings",
29
+ },
30
+ args: uninstallArgs,
31
+ async run({ args, rawArgs }) {
32
+ rejectUnknownArgs(rawArgs, uninstallArgs);
33
+ const scope = args.global ? "global" : "project";
34
+ const dryRun = args["dry-run"] ?? false;
35
+ const settingsPath = getSettingsPath(scope);
36
+
37
+ const settings = await readSettings(settingsPath);
38
+ const updated = removeHook(settings);
39
+
40
+ if (!updated) {
41
+ console.log(`tyr hook not found in ${settingsPath}`);
42
+ return;
43
+ }
44
+
45
+ if (dryRun) {
46
+ console.log(`Would write to ${settingsPath}:\n`);
47
+ console.log(JSON.stringify(updated, null, 2));
48
+ return;
49
+ }
50
+
51
+ await writeSettings(settingsPath, updated);
52
+ console.log(`Removed tyr hook from ${settingsPath}`);
53
+ },
54
+ });
@@ -0,0 +1,14 @@
1
+ import { defineCommand } from "citty";
2
+ import { VERSION } from "../version.ts";
3
+
4
+ export default defineCommand({
5
+ meta: {
6
+ name: "version",
7
+ description: "Print tyr version and runtime info",
8
+ },
9
+ run() {
10
+ console.log(`tyr ${VERSION}`);
11
+ console.log(`bun ${Bun.version}`);
12
+ console.log(`${process.platform} ${process.arch}`);
13
+ },
14
+ });
package/src/config.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { PROVIDER_NAMES, type TyrConfig, TyrConfigSchema } from "./types.ts";
6
+
7
+ const CONFIG_DIR = join(homedir(), ".config", "tyr");
8
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
9
+
10
+ /** Return the path to tyr's config file. */
11
+ export function getConfigPath(): string {
12
+ return process.env.TYR_CONFIG_FILE ?? CONFIG_FILE;
13
+ }
14
+
15
+ /** Return the path to tyr's `.env` file (next to config.json). */
16
+ export function getEnvPath(): string {
17
+ return join(dirname(getConfigPath()), ".env");
18
+ }
19
+
20
+ /** Parse `.env` file content into key-value pairs. */
21
+ function parseEnv(text: string): Record<string, string> {
22
+ const result: Record<string, string> = {};
23
+ for (const line of text.split("\n")) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed || trimmed.startsWith("#")) continue;
26
+ const eq = trimmed.indexOf("=");
27
+ if (eq === -1) continue;
28
+ const key = trimmed.slice(0, eq).trim();
29
+ let value = trimmed.slice(eq + 1).trim();
30
+ // Strip matching quotes
31
+ if (
32
+ (value.startsWith('"') && value.endsWith('"')) ||
33
+ (value.startsWith("'") && value.endsWith("'"))
34
+ ) {
35
+ value = value.slice(1, -1);
36
+ }
37
+ result[key] = value;
38
+ }
39
+ return result;
40
+ }
41
+
42
+ /** Load `.env` from the tyr config directory into `process.env`.
43
+ * Existing env vars take precedence. No-ops if the file doesn't exist. */
44
+ export function loadEnvFile(): void {
45
+ const vars = readEnvFile();
46
+ for (const [key, value] of Object.entries(vars)) {
47
+ if (process.env[key] === undefined) {
48
+ process.env[key] = value;
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Read and return parsed key-value pairs from the `.env` file. */
54
+ export function readEnvFile(): Record<string, string> {
55
+ const envPath = getEnvPath();
56
+ let text: string;
57
+ try {
58
+ text = readFileSync(envPath, "utf-8");
59
+ } catch {
60
+ return {};
61
+ }
62
+ return parseEnv(text);
63
+ }
64
+
65
+ /** Upsert a key in the `.env` file. Creates the file if missing. */
66
+ export function writeEnvVar(key: string, value: string): void {
67
+ const envPath = getEnvPath();
68
+ let lines: string[] = [];
69
+ if (existsSync(envPath)) {
70
+ lines = readFileSync(envPath, "utf-8").split("\n");
71
+ }
72
+
73
+ const prefix = `${key}=`;
74
+ let found = false;
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const line = lines[i];
77
+ if (line === undefined) continue;
78
+ const trimmed = line.trim();
79
+ if (trimmed.startsWith("#")) continue;
80
+ if (trimmed.startsWith(prefix) || trimmed.startsWith(`${key} =`)) {
81
+ lines[i] = `${key}=${value}`;
82
+ found = true;
83
+ break;
84
+ }
85
+ }
86
+ if (!found) {
87
+ lines.push(`${key}=${value}`);
88
+ }
89
+
90
+ // Ensure trailing newline
91
+ const content = lines.join("\n").replace(/\n*$/, "\n");
92
+ const dir = dirname(envPath);
93
+ mkdirSync(dir, { recursive: true });
94
+ writeFileSync(envPath, content, "utf-8");
95
+ }
96
+
97
+ /** Map of all settable config key paths to their expected types. */
98
+ const VALID_KEY_TYPES: Record<
99
+ string,
100
+ "boolean" | "string" | "number" | "providers"
101
+ > = {
102
+ providers: "providers",
103
+ failOpen: "boolean",
104
+ verboseLog: "boolean",
105
+ logRetention: "string",
106
+ "claude.model": "string",
107
+ "claude.timeout": "number",
108
+ "claude.canDeny": "boolean",
109
+ "openrouter.model": "string",
110
+ "openrouter.endpoint": "string",
111
+ "openrouter.timeout": "number",
112
+ "openrouter.canDeny": "boolean",
113
+ };
114
+
115
+ /** Check if a string is a valid config key (supports dot notation for claude.*, openrouter.*). */
116
+ export function isValidKey(key: string): boolean {
117
+ return key in VALID_KEY_TYPES;
118
+ }
119
+
120
+ /** Strip // and /* comments from a JSON string, preserving strings. */
121
+ export function stripJsonComments(text: string): string {
122
+ let result = "";
123
+ let i = 0;
124
+ while (i < text.length) {
125
+ // String literal — copy verbatim including escapes
126
+ if (text[i] === '"') {
127
+ let j = i + 1;
128
+ while (j < text.length && text[j] !== '"') {
129
+ if (text[j] === "\\") j++; // skip escaped char
130
+ j++;
131
+ }
132
+ result += text.slice(i, j + 1);
133
+ i = j + 1;
134
+ continue;
135
+ }
136
+ // Single-line comment
137
+ if (text[i] === "/" && text[i + 1] === "/") {
138
+ const nl = text.indexOf("\n", i);
139
+ i = nl === -1 ? text.length : nl;
140
+ continue;
141
+ }
142
+ // Block comment
143
+ if (text[i] === "/" && text[i + 1] === "*") {
144
+ const end = text.indexOf("*/", i + 2);
145
+ i = end === -1 ? text.length : end + 2;
146
+ continue;
147
+ }
148
+ result += text[i];
149
+ i++;
150
+ }
151
+ return result;
152
+ }
153
+
154
+ /** Read the raw JSON from tyr's config file without schema validation.
155
+ * Returns an empty object when the file is missing. Supports JSONC. */
156
+ export async function readRawConfig(): Promise<Record<string, unknown>> {
157
+ const path = getConfigPath();
158
+ let text: string;
159
+ try {
160
+ text = await readFile(path, "utf-8");
161
+ } catch (err: unknown) {
162
+ if (
163
+ err instanceof Error &&
164
+ "code" in err &&
165
+ (err as NodeJS.ErrnoException).code === "ENOENT"
166
+ ) {
167
+ return {};
168
+ }
169
+ throw err;
170
+ }
171
+ return JSON.parse(stripJsonComments(text)) as Record<string, unknown>;
172
+ }
173
+
174
+ /** Read tyr's config. Returns defaults when the file is missing.
175
+ * Supports JSONC (JSON with Comments). Rejects unrecognized keys. */
176
+ export async function readConfig(): Promise<TyrConfig> {
177
+ const raw = await readRawConfig();
178
+ return TyrConfigSchema.strict().parse(raw);
179
+ }
180
+
181
+ /** Write config to disk, creating parent directories as needed. */
182
+ export async function writeConfig(config: TyrConfig): Promise<void> {
183
+ const path = getConfigPath();
184
+ await mkdir(dirname(path), { recursive: true });
185
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
186
+ }
187
+
188
+ /** Parse a string value into the expected type for a config key path. */
189
+ export function parseValue(
190
+ key: string,
191
+ value: string,
192
+ ): boolean | string | number | string[] | null {
193
+ const expected = VALID_KEY_TYPES[key];
194
+ if (!expected) return null;
195
+ if (expected === "boolean") {
196
+ if (value === "true") return true;
197
+ if (value === "false") return false;
198
+ return null;
199
+ }
200
+ if (expected === "string") {
201
+ return value;
202
+ }
203
+ if (expected === "number") {
204
+ if (value.trim() === "") return null;
205
+ const num = Number(value);
206
+ if (Number.isFinite(num)) return num;
207
+ return null;
208
+ }
209
+ if (expected === "providers") {
210
+ const valid = new Set<string>(PROVIDER_NAMES);
211
+ const names = value
212
+ .split(",")
213
+ .map((s) => s.trim())
214
+ .filter(Boolean);
215
+ if (names.length === 0) return null;
216
+ for (const n of names) {
217
+ if (!valid.has(n)) return null;
218
+ }
219
+ return names;
220
+ }
221
+ return null;
222
+ }