@panchr/tyr 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -54,37 +54,15 @@ tyr uninstall # project
54
54
  tyr uninstall --global # global
55
55
  ```
56
56
 
57
- Use `--dry-run` with either command to preview changes without modifying anything.
57
+ Use `--dry-run` with either command to preview changes without modifying anything. Run `tyr --help` for the full command reference.
58
58
 
59
- ## Usage
60
-
61
- ### Commands
62
-
63
- ```
64
- tyr install [--global] [--project] [--dry-run] [--shadow|--audit]
65
- tyr uninstall [--global] [--project] [--dry-run]
66
- tyr config show
67
- tyr config set <key> <value>
68
- tyr config path
69
- tyr config env set <key> <value>
70
- tyr config env show
71
- tyr config env path
72
- tyr log [--last N] [--json] [--since T] [--until T] [--decision D] [--provider P] [--cwd C] [--verbose]
73
- tyr log clear
74
- tyr db migrate
75
- tyr stats [--since T] [--json]
76
- tyr suggest [--global|--project] [--min-count N] [--all]
77
- tyr debug claude-config [--cwd C]
78
- tyr version
79
- ```
80
-
81
- ### Configuration
59
+ ## Configuration
82
60
 
83
61
  Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_CONFIG_FILE`). The config file supports JSON with comments (JSONC).
84
62
 
85
63
  | Key | Type | Default | Description |
86
64
  |-----|------|---------|-------------|
87
- | `providers` | string[] | `["chained-commands", "claude"]` | Ordered list of providers to run |
65
+ | `providers` | string[] | `["chained-commands"]` | Ordered list of providers to run |
88
66
  | `failOpen` | boolean | `false` | Approve on error instead of failing closed |
89
67
  | `claude.model` | string | `"haiku"` | Model identifier for the Claude CLI |
90
68
  | `claude.timeout` | number | `10` | Claude request timeout in seconds |
@@ -97,40 +75,25 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
97
75
  | `verboseLog` | boolean | `false` | Include LLM prompt/params in log entries |
98
76
  | `logRetention` | string | `"30d"` | Auto-prune logs older than this (`"0"` to disable) |
99
77
 
100
- All config values can be overridden per-invocation via CLI flags on the `judge` command (e.g. `--fail-open`, `--claude-model`). These flags are passed through the hook configuration in `.claude/settings.json`.
78
+ Use `tyr config show` to view the current config, `tyr config set <key> <value>` to update a value, `tyr config example` to print a recommended starter config, and `tyr config schema` to print the JSON Schema.
101
79
 
102
80
  ### Environment variables
103
81
 
104
- Tyr loads environment variables from `~/.config/tyr/.env` (next to the config file). This is the recommended place to store API keys.
105
-
106
- ```bash
107
- # Store your OpenRouter API key
108
- tyr config env set OPENROUTER_API_KEY sk-or-...
109
-
110
- # View stored variables (values masked)
111
- tyr config env show
112
-
113
- # Print .env file path
114
- tyr config env path
115
- ```
116
-
117
- Existing process environment variables take precedence over `.env` values.
82
+ Tyr loads environment variables from `~/.config/tyr/.env` (next to the config file). This is the recommended place to store API keys (e.g., `OPENROUTER_API_KEY`). Use `tyr config env set <key> <value>` to manage them. Existing process environment variables take precedence.
118
83
 
119
- ### Providers
84
+ ## Providers
120
85
 
121
86
  Tyr uses a **pipeline architecture** where providers are evaluated in sequence. The first provider to return a definitive `allow` or `deny` wins --- remaining providers are skipped. If all providers `abstain`, the request falls through to Claude Code's default behavior (prompting the user), unless `failOpen` is `true`, in which case the request is approved.
122
87
 
123
88
  Configure the pipeline via the `providers` array. **Order matters** -- providers run in order.
124
89
 
125
- Valid providers are listed below.
126
-
127
- #### `cache`
90
+ ### `cache`
128
91
 
129
92
  Caches prior decisions in SQLite. If the same command was previously allowed or denied (with the same config and permission rules), returns the cached result immediately. The cache auto-invalidates when your config or Claude Code permission rules change.
130
93
 
131
94
  **Best practice:** Place first in the pipeline to skip expensive downstream evaluations.
132
95
 
133
- #### `chained-commands`
96
+ ### `chained-commands`
134
97
 
135
98
  Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns.
136
99
 
@@ -138,7 +101,7 @@ Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substit
138
101
  - **Deny:** _Any_ sub-command matches a deny pattern
139
102
  - **Abstain:** Any sub-command has no matching pattern
140
103
 
141
- #### `claude`
104
+ ### `claude`
142
105
 
143
106
  Sends ambiguous commands to the local Claude CLI for semantic evaluation. The LLM sees your permission rules, the command, and the working directory, then reasons about whether the command is safe.
144
107
 
@@ -146,19 +109,13 @@ When `claude.canDeny` is `false` (the default), the LLM can only approve command
146
109
 
147
110
  When `conversationContext` is enabled, the LLM also sees recent conversation messages from the Claude Code session. This lets it allow commands that don't match any configured pattern if the user clearly requested the action and it's a typical, safe development command. The deny list is always checked first -- no amount of context overrides a denied pattern.
148
111
 
149
- Requires a local `claude` CLI binary. While this is somewhat slow due to the subprocess required to run `claude` (generally a decision is made in about 5 seconds), this slowness is acceptable given that it will still be faster than a human understanding and approving a command.
150
-
151
- The main benefit of this provider is that it reuses the authentication that `claude` is already configured with, whether that's an Anthropic API key or a subscription.
152
-
153
- #### `openrouter`
112
+ Note: this provider adds ~5 seconds of latency per evaluation due to the subprocess overhead, but this is still faster than a human reviewing and approving a command. It also reuses whatever authentication `claude` is already configured with.
154
113
 
155
- Sends ambiguous commands to the OpenRouter API for evaluation. Same semantics as the `claude` provider but uses an HTTP API instead of the local CLI. Supports `conversationContext` in the same way.
114
+ ### `openrouter`
156
115
 
157
- Requires `OPENROUTER_API_KEY` set in your environment or `.env` file.
116
+ Same semantics as the `claude` provider but uses the OpenRouter HTTP API instead of the local CLI. Supports `conversationContext` in the same way. Requires `OPENROUTER_API_KEY`.
158
117
 
159
- Only evaluates `Bash` tool requests; abstains on all other tools.
160
-
161
- #### Pipeline examples
118
+ ### Pipeline examples
162
119
 
163
120
  ```jsonc
164
121
  // Safe & fast (default) -- pattern matching only
@@ -174,82 +131,20 @@ Only evaluates `Bash` tool requests; abstains on all other tools.
174
131
  { "providers": ["cache", "chained-commands", "openrouter"] }
175
132
  ```
176
133
 
177
- ### Viewing logs
178
-
179
- Every permission decision is logged to a SQLite database at `~/.local/share/tyr/tyr.db` (overridable via `TYR_DB_PATH`).
180
-
181
- ```bash
182
- # View recent decisions (default: last 20)
183
- tyr log
184
-
185
- # Show more entries
186
- tyr log --last 50
187
-
188
- # Filter by decision type
189
- tyr log --decision allow
190
- tyr log --decision deny
191
-
192
- # Filter by time range (ISO or relative: 1h, 30m, 7d)
193
- tyr log --since 1h
194
- tyr log --since 2025-01-01 --until 2025-01-31
195
-
196
- # Filter by provider or working directory
197
- tyr log --provider chained-commands
198
- tyr log --cwd /path/to/project
199
-
200
- # JSON output
201
- tyr log --json
202
-
203
- # Show LLM prompts for verbose-logged entries
204
- tyr log --verbose
205
-
206
- # Clear all logs
207
- tyr log clear
208
- ```
209
-
210
- Log entries are automatically pruned based on the `logRetention` config setting (default: 30 days).
211
-
212
- ### Statistics
213
-
214
- ```bash
215
- # View overall stats
216
- tyr stats
217
-
218
- # Stats for the last 7 days
219
- tyr stats --since 7d
220
-
221
- # Machine-readable JSON
222
- tyr stats --json
223
- ```
224
-
225
- Shows: total checks, decision breakdown (allow/deny/abstain/error), cache hit rate, provider distribution, and auto-approval count.
134
+ ### Permission prompt delay
226
135
 
227
- ### Suggestions
136
+ When tyr is installed as a hook, Claude Code shows the permission prompt and calls the hook concurrently. You'll see the prompt appear immediately while tyr evaluates the command in the background. If the hook decides to allow or deny, the prompt is automatically resolved; if the hook abstains, the prompt remains for you to decide manually.
228
137
 
229
- Tyr can analyze your decision history and start an interactive Claude session to help you refine and apply allow rules:
138
+ ## Database management
230
139
 
231
- ```bash
232
- # Start an interactive session with suggested rules (commands approved >= 5 times)
233
- tyr suggest
234
-
235
- # Lower the threshold for which commands are surfaced
236
- tyr suggest --min-count 3
140
+ Tyr stores all decision logs and cached results in a SQLite database at `~/.local/share/tyr/tyr.db` (overridable via `TYR_DB_PATH`).
237
141
 
238
- # Target project settings instead of global
239
- tyr suggest --project
142
+ | Command | Description |
143
+ |---------|-------------|
144
+ | `tyr db migrate` | Run pending schema migrations after upgrading tyr |
145
+ | `tyr db rename <old> <new>` | Update stored project paths after moving a directory |
240
146
 
241
- # Include commands from all projects, not just the current directory
242
- tyr suggest --all
243
- ```
244
-
245
- ### Database migrations
246
-
247
- When upgrading from one `tyr` version to another, run
248
-
249
- ```bash
250
- # Run pending schema migrations
251
- tyr db migrate
252
- ```
147
+ `tyr db rename` is useful when you relocate a project on disk. It rewrites the `cwd` column in both the logs and cache tables (including subpaths) so that `tyr log`, `tyr stats`, and cache lookups continue to work for the moved project.
253
148
 
254
149
  ## Development
255
150
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Intelligent permission management for Claude Code hooks",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "0.2.0",
6
+ "version": "0.2.2",
7
7
  "bin": {
8
8
  "tyr": "src/index.ts"
9
9
  },
@@ -1,4 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
+ import { z } from "zod/v4";
2
3
  import {
3
4
  getConfigPath,
4
5
  getEnvPath,
@@ -167,6 +168,51 @@ const env = defineCommand({
167
168
  },
168
169
  });
169
170
 
171
+ const EXAMPLE_CONFIG = `{
172
+ // Pattern matching with caching for fast repeated evaluations.
173
+ // Add "claude" or "openrouter" after "chained-commands" to use an
174
+ // LLM for commands that don't match any allow/deny pattern.
175
+ "providers": ["cache", "chained-commands"],
176
+
177
+ // Fail closed: deny on error rather than auto-approving.
178
+ "failOpen": false,
179
+
180
+ // Auto-prune log entries older than 30 days.
181
+ "logRetention": "30d"
182
+ }
183
+ `;
184
+
185
+ const example = defineCommand({
186
+ meta: {
187
+ name: "example",
188
+ description: "Print a recommended example config",
189
+ },
190
+ run() {
191
+ process.stdout.write(EXAMPLE_CONFIG);
192
+ },
193
+ });
194
+
195
+ const schema = defineCommand({
196
+ meta: {
197
+ name: "schema",
198
+ description: "Print the config JSON Schema",
199
+ },
200
+ run() {
201
+ const jsonSchema = z.toJSONSchema(TyrConfigSchema, {
202
+ target: "draft-2020-12",
203
+ });
204
+ // z.toJSONSchema cannot represent .refine() constraints;
205
+ // manually add the pattern for logRetention.
206
+ const props = (jsonSchema as { properties?: Record<string, object> })
207
+ .properties;
208
+ if (props?.logRetention) {
209
+ (props.logRetention as Record<string, unknown>).pattern =
210
+ "^(0|\\d+[smhd])$";
211
+ }
212
+ console.log(JSON.stringify(jsonSchema, null, 2));
213
+ },
214
+ });
215
+
170
216
  export default defineCommand({
171
217
  meta: {
172
218
  name: "config",
@@ -176,6 +222,8 @@ export default defineCommand({
176
222
  show,
177
223
  set,
178
224
  path,
225
+ example,
226
+ schema,
179
227
  env,
180
228
  },
181
229
  });
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { defineCommand } from "citty";
2
3
  import {
3
4
  CURRENT_SCHEMA_VERSION,
@@ -55,6 +56,62 @@ const migrate = defineCommand({
55
56
  },
56
57
  });
57
58
 
59
+ const rename = defineCommand({
60
+ meta: {
61
+ name: "rename",
62
+ description:
63
+ "Rename a project directory in the database (e.g. after moving a project)",
64
+ },
65
+ args: {
66
+ oldPath: {
67
+ type: "positional",
68
+ description: "Current project directory path",
69
+ required: true,
70
+ },
71
+ newPath: {
72
+ type: "positional",
73
+ description: "New project directory path",
74
+ required: true,
75
+ },
76
+ },
77
+ async run({ args }) {
78
+ const oldPath = resolve(args.oldPath as string);
79
+ const newPath = resolve(args.newPath as string);
80
+
81
+ if (oldPath === newPath) {
82
+ console.error("Old and new paths are the same.");
83
+ process.exit(1);
84
+ }
85
+
86
+ const db = openRawDb();
87
+
88
+ if (!hasMetaTable(db)) {
89
+ console.error("Database is uninitialized. Nothing to rename.");
90
+ db.close();
91
+ process.exit(1);
92
+ }
93
+
94
+ const escapedOld = oldPath.replace(/[\\%_]/g, "\\$&");
95
+
96
+ const updated = db.transaction(() => {
97
+ let total = 0;
98
+ for (const table of ["logs", "cache"] as const) {
99
+ const result = db
100
+ .query(
101
+ `UPDATE ${table} SET cwd = ? || substr(cwd, length(?) + 1)
102
+ WHERE cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\'`,
103
+ )
104
+ .run(newPath, oldPath, oldPath, escapedOld);
105
+ total += result.changes;
106
+ }
107
+ return total;
108
+ })();
109
+
110
+ console.log(`Renamed ${oldPath} → ${newPath} (${updated} row(s) updated)`);
111
+ db.close();
112
+ },
113
+ });
114
+
58
115
  export default defineCommand({
59
116
  meta: {
60
117
  name: "db",
@@ -62,5 +119,6 @@ export default defineCommand({
62
119
  },
63
120
  subCommands: {
64
121
  migrate,
122
+ rename,
65
123
  },
66
124
  });
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { ClaudeAgent } from "../agents/claude.ts";
3
+ import { getRepoRoot } from "../repo.ts";
3
4
 
4
5
  const claudeConfig = defineCommand({
5
6
  meta: {
@@ -13,7 +14,7 @@ const claudeConfig = defineCommand({
13
14
  },
14
15
  },
15
16
  async run({ args }) {
16
- const cwd = (args.cwd as string | undefined) ?? process.cwd();
17
+ const cwd = (args.cwd as string | undefined) ?? getRepoRoot();
17
18
  const agent = new ClaudeAgent();
18
19
  await agent.init(cwd);
19
20
  const info = agent.getDebugInfo();
@@ -10,6 +10,7 @@ import {
10
10
  readLogEntries,
11
11
  truncateOldLogs,
12
12
  } from "../log.ts";
13
+ import { getRepoRoot } from "../repo.ts";
13
14
 
14
15
  function formatTime(ts: number): string {
15
16
  const d = new Date(ts);
@@ -145,7 +146,7 @@ export default defineCommand({
145
146
  }
146
147
 
147
148
  // Default to current directory unless --all or --cwd is provided
148
- const cwdFilter = args.all ? undefined : (args.cwd ?? process.cwd());
149
+ const cwdFilter = args.all ? undefined : (args.cwd ?? getRepoRoot());
149
150
 
150
151
  const entries = readLogEntries({
151
152
  last: last > 0 ? last : undefined,
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { defineCommand } from "citty";
@@ -9,6 +10,7 @@ import {
9
10
  import { rejectUnknownArgs } from "../args.ts";
10
11
  import { closeDb, getDb } from "../db.ts";
11
12
  import { readSettings } from "../install.ts";
13
+ import { getRepoRoot } from "../repo.ts";
12
14
 
13
15
  const suggestArgs = {
14
16
  global: {
@@ -92,35 +94,43 @@ export function getSuggestions(
92
94
  return suggestions;
93
95
  }
94
96
 
95
- function buildSuggestSystemPrompt(
97
+ export function buildSuggestPrompt(
96
98
  suggestions: Suggestion[],
97
- settingsPath: string,
99
+ targetPath: string,
100
+ allPaths: string[],
98
101
  ): string {
99
102
  const commandList = suggestions
100
103
  .map((s) => `- \`${s.command}\` (approved ${s.count} times)`)
101
104
  .join("\n");
102
105
 
103
- return `You are helping configure permission rules for Claude Code.
106
+ const pathList = allPaths.map((p) => `- \`${p}\``).join("\n");
104
107
 
105
- The user has been manually approving shell commands while using Claude Code. Tyr has identified frequently-approved commands that could be added as permanent allow rules.
108
+ return `I've been manually approving shell commands while using Claude Code. Tyr has identified frequently-approved commands that could be added as permanent allow rules.
106
109
 
107
110
  ## Frequently Approved Commands (not yet in allow rules)
108
111
 
109
112
  ${commandList}
110
113
 
111
- ## Settings File
112
- - Path: ${settingsPath}
114
+ ## Settings Files
115
+
116
+ Claude Code reads permissions from multiple settings files:
117
+
118
+ ${pathList}
119
+
120
+ ## Target Settings File
121
+ - Write new rules to: \`${targetPath}\`
113
122
  - Format: JSON with a \`permissions.allow\` array of strings
114
- - Each rule is a string like \`Bash(pattern)\` where \`pattern\` can use \`*\` as a glob wildcard
123
+ - Each rule MUST use the exact format \`Bash(pattern)\` where \`pattern\` can use \`*\` as a glob wildcard
115
124
  - Example: \`Bash(bun *)\` allows any command starting with \`bun \`
125
+ - IMPORTANT: Before writing rules, read the settings files listed above (those that exist) to understand existing permissions, then merge new rules into the target file's \`permissions.allow\` array
116
126
 
117
- ## Your Task
118
- Help the user decide which commands to add as allow rules:
127
+ ## Instructions
128
+ Help me decide which commands to add as allow rules:
119
129
  1. Suggest generalized glob patterns that group similar commands (e.g., "bun test" and "bun lint" → "Bash(bun *)")
120
130
  2. Explain what each pattern would match
121
- 3. When the user is ready, write the rules to the settings file at the path above
131
+ 3. When I'm ready, write the rules to the target settings file
122
132
 
123
- Be concise. Start by presenting your suggested rules and ask if the user wants to adjust them.`;
133
+ Be concise. Start by presenting your suggested rules and ask if I want to adjust them.`;
124
134
  }
125
135
 
126
136
  export default defineCommand({
@@ -146,7 +156,8 @@ export default defineCommand({
146
156
  return;
147
157
  }
148
158
 
149
- const allPaths = settingsPaths(process.cwd());
159
+ const repoRoot = getRepoRoot();
160
+ const allPaths = settingsPaths(repoRoot);
150
161
  const allowPatterns: string[] = [];
151
162
  for (const path of allPaths) {
152
163
  const settings = await readSettings(path);
@@ -156,7 +167,7 @@ export default defineCommand({
156
167
  }
157
168
  }
158
169
 
159
- const cwdFilter = args.all ? undefined : process.cwd();
170
+ const cwdFilter = args.all ? undefined : repoRoot;
160
171
  const suggestions = getSuggestions(minCount, allowPatterns, cwdFilter);
161
172
  closeDb();
162
173
 
@@ -165,17 +176,18 @@ export default defineCommand({
165
176
  return;
166
177
  }
167
178
 
168
- const scope: "global" | "project" = args.project ? "project" : "global";
179
+ const scope: "global" | "project" = args.global ? "global" : "project";
169
180
  const configDir =
170
181
  process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
171
- const settingsPath =
182
+ const targetPath =
172
183
  scope === "global"
173
184
  ? join(configDir, "settings.json")
174
- : join(process.cwd(), ".claude", "settings.json");
185
+ : join(repoRoot, ".claude", "settings.json");
175
186
 
176
- const systemPrompt = buildSuggestSystemPrompt(suggestions, settingsPath);
187
+ const existingPaths = allPaths.filter((p) => existsSync(p));
188
+ const prompt = buildSuggestPrompt(suggestions, targetPath, existingPaths);
177
189
 
178
- const proc = Bun.spawn(["claude", "--append-system-prompt", systemPrompt], {
190
+ const proc = Bun.spawn(["claude", prompt], {
179
191
  stdin: "inherit",
180
192
  stdout: "inherit",
181
193
  stderr: "inherit",
package/src/index.ts CHANGED
File without changes
package/src/install.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { getRepoRoot } from "./repo.ts";
4
5
 
5
6
  export type JudgeMode = "shadow" | "audit" | undefined;
6
7
 
@@ -20,7 +21,7 @@ export function getSettingsPath(scope: "global" | "project"): string {
20
21
  if (scope === "global") {
21
22
  return join(homedir(), ".claude", "settings.json");
22
23
  }
23
- return join(process.cwd(), ".claude", "settings.json");
24
+ return join(getRepoRoot(), ".claude", "settings.json");
24
25
  }
25
26
 
26
27
  /** Read and parse a settings.json, returning {} if it doesn't exist. */
package/src/repo.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ /**
5
+ * Walk up from `startDir` looking for a `.git` directory.
6
+ * Returns the repo root or `null` if not inside a git repository.
7
+ */
8
+ export function findRepoRoot(startDir: string): string | null {
9
+ let dir = resolve(startDir);
10
+ for (;;) {
11
+ if (existsSync(join(dir, ".git"))) return dir;
12
+ const parent = dirname(dir);
13
+ if (parent === dir) return null;
14
+ dir = parent;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Returns the repo root for the current directory, falling back to
20
+ * `process.cwd()` when not inside a git repository.
21
+ */
22
+ export function getRepoRoot(): string {
23
+ return findRepoRoot(process.cwd()) ?? process.cwd();
24
+ }