@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.
- package/README.md +276 -0
- package/package.json +32 -0
- package/src/agents/claude.ts +143 -0
- package/src/args.ts +55 -0
- package/src/cache.ts +84 -0
- package/src/commands/config.ts +181 -0
- package/src/commands/db.ts +66 -0
- package/src/commands/debug.ts +34 -0
- package/src/commands/install.ts +77 -0
- package/src/commands/judge.ts +399 -0
- package/src/commands/log.ts +189 -0
- package/src/commands/stats.ts +154 -0
- package/src/commands/suggest.ts +184 -0
- package/src/commands/uninstall.ts +54 -0
- package/src/commands/version.ts +14 -0
- package/src/config.ts +222 -0
- package/src/db.ts +229 -0
- package/src/index.ts +36 -0
- package/src/install.ts +116 -0
- package/src/judge.ts +19 -0
- package/src/log.ts +193 -0
- package/src/pipeline.ts +32 -0
- package/src/prompts.ts +83 -0
- package/src/providers/cache.ts +34 -0
- package/src/providers/chained-commands.ts +45 -0
- package/src/providers/claude.ts +120 -0
- package/src/providers/openrouter.ts +112 -0
- package/src/providers/shell-parser.ts +76 -0
- package/src/types/mvdan-sh.d.ts +23 -0
- package/src/types.ts +109 -0
- package/src/version.ts +9 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { ClaudeAgent } from "../agents/claude.ts";
|
|
3
|
+
import { rejectUnknownArgs } from "../args.ts";
|
|
4
|
+
import { computeConfigHash } from "../cache.ts";
|
|
5
|
+
import { loadEnvFile, parseValue, readConfig } from "../config.ts";
|
|
6
|
+
import { closeDb } from "../db.ts";
|
|
7
|
+
import { parsePermissionRequest, readStdin } from "../judge.ts";
|
|
8
|
+
import {
|
|
9
|
+
appendLogEntry,
|
|
10
|
+
extractToolInput,
|
|
11
|
+
type LlmLogEntry,
|
|
12
|
+
type LogEntry,
|
|
13
|
+
truncateOldLogs,
|
|
14
|
+
} from "../log.ts";
|
|
15
|
+
import { runPipeline } from "../pipeline.ts";
|
|
16
|
+
import { buildPrompt } from "../prompts.ts";
|
|
17
|
+
import { CacheProvider } from "../providers/cache.ts";
|
|
18
|
+
import { ChainedCommandsProvider } from "../providers/chained-commands.ts";
|
|
19
|
+
import { ClaudeProvider } from "../providers/claude.ts";
|
|
20
|
+
import { OpenRouterProvider } from "../providers/openrouter.ts";
|
|
21
|
+
import type { HookResponse, Provider, TyrConfig } from "../types.ts";
|
|
22
|
+
import { resolveProviders } from "../types.ts";
|
|
23
|
+
|
|
24
|
+
const judgeArgs = {
|
|
25
|
+
verbose: {
|
|
26
|
+
type: "boolean" as const,
|
|
27
|
+
description: "Emit debug info to stderr",
|
|
28
|
+
},
|
|
29
|
+
shadow: {
|
|
30
|
+
type: "boolean" as const,
|
|
31
|
+
description:
|
|
32
|
+
"Run the full pipeline but always abstain; the real decision is only logged",
|
|
33
|
+
},
|
|
34
|
+
audit: {
|
|
35
|
+
type: "boolean" as const,
|
|
36
|
+
description: "Skip the pipeline entirely; just log the request and abstain",
|
|
37
|
+
},
|
|
38
|
+
// Config overrides (kebab-case flags that override config file values)
|
|
39
|
+
providers: {
|
|
40
|
+
type: "string" as const,
|
|
41
|
+
description:
|
|
42
|
+
"Override providers list (comma-separated: cache,chained-commands,claude,openrouter)",
|
|
43
|
+
},
|
|
44
|
+
"fail-open": {
|
|
45
|
+
type: "boolean" as const,
|
|
46
|
+
description: "Override failOpen config",
|
|
47
|
+
},
|
|
48
|
+
"claude-model": {
|
|
49
|
+
type: "string" as const,
|
|
50
|
+
description: "Override claude.model config",
|
|
51
|
+
},
|
|
52
|
+
"claude-timeout": {
|
|
53
|
+
type: "string" as const,
|
|
54
|
+
description: "Override claude.timeout config (seconds)",
|
|
55
|
+
},
|
|
56
|
+
"claude-can-deny": {
|
|
57
|
+
type: "boolean" as const,
|
|
58
|
+
description: "Override claude.canDeny config",
|
|
59
|
+
},
|
|
60
|
+
"openrouter-model": {
|
|
61
|
+
type: "string" as const,
|
|
62
|
+
description: "Override openrouter.model config",
|
|
63
|
+
},
|
|
64
|
+
"openrouter-endpoint": {
|
|
65
|
+
type: "string" as const,
|
|
66
|
+
description: "Override openrouter.endpoint config",
|
|
67
|
+
},
|
|
68
|
+
"openrouter-timeout": {
|
|
69
|
+
type: "string" as const,
|
|
70
|
+
description: "Override openrouter.timeout config (seconds)",
|
|
71
|
+
},
|
|
72
|
+
"openrouter-can-deny": {
|
|
73
|
+
type: "boolean" as const,
|
|
74
|
+
description: "Override openrouter.canDeny config",
|
|
75
|
+
},
|
|
76
|
+
"verbose-log": {
|
|
77
|
+
type: "boolean" as const,
|
|
78
|
+
description: "Include LLM prompt and parameters in log entries",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default defineCommand({
|
|
83
|
+
meta: {
|
|
84
|
+
name: "judge",
|
|
85
|
+
description: "Evaluate a permission request (hook entry point)",
|
|
86
|
+
},
|
|
87
|
+
args: judgeArgs,
|
|
88
|
+
async run({ args, rawArgs }) {
|
|
89
|
+
rejectUnknownArgs(rawArgs, judgeArgs);
|
|
90
|
+
const verbose = args.verbose ?? false;
|
|
91
|
+
const shadow = args.shadow ?? false;
|
|
92
|
+
const audit = args.audit ?? false;
|
|
93
|
+
|
|
94
|
+
if (shadow && audit) {
|
|
95
|
+
console.error("[tyr] --shadow and --audit are mutually exclusive");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const startTime = performance.now();
|
|
101
|
+
|
|
102
|
+
let raw: string;
|
|
103
|
+
try {
|
|
104
|
+
raw = await readStdin();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (verbose) console.error("[tyr] failed to read stdin:", err);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (verbose) console.error("[tyr] stdin:", raw);
|
|
112
|
+
|
|
113
|
+
let data: unknown;
|
|
114
|
+
try {
|
|
115
|
+
data = JSON.parse(raw);
|
|
116
|
+
} catch {
|
|
117
|
+
if (verbose) console.error("[tyr] malformed JSON input");
|
|
118
|
+
process.exit(2);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const req = parsePermissionRequest(data);
|
|
123
|
+
if (!req) {
|
|
124
|
+
if (verbose) console.error("[tyr] invalid PermissionRequest shape");
|
|
125
|
+
process.exit(2);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (verbose) {
|
|
130
|
+
console.error(
|
|
131
|
+
`[tyr] tool=${req.tool_name} input=${JSON.stringify(req.tool_input)}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Audit mode: log the request and exit without running the pipeline
|
|
136
|
+
if (audit) {
|
|
137
|
+
const duration = performance.now() - startTime;
|
|
138
|
+
const toolInput = extractToolInput(req.tool_name, req.tool_input);
|
|
139
|
+
const entry: LogEntry = {
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
cwd: req.cwd,
|
|
142
|
+
tool_name: req.tool_name,
|
|
143
|
+
tool_input: toolInput,
|
|
144
|
+
input: JSON.stringify(req.tool_input),
|
|
145
|
+
decision: "abstain",
|
|
146
|
+
provider: null,
|
|
147
|
+
duration_ms: Math.round(duration),
|
|
148
|
+
session_id: req.session_id,
|
|
149
|
+
mode: "audit",
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
appendLogEntry(entry);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (verbose) console.error("[tyr] failed to write log:", err);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const auditConfig = await readConfig();
|
|
158
|
+
truncateOldLogs(auditConfig.logRetention);
|
|
159
|
+
} catch {
|
|
160
|
+
// best-effort
|
|
161
|
+
}
|
|
162
|
+
if (verbose) {
|
|
163
|
+
console.error("[tyr] audit mode: logged request, skipping pipeline");
|
|
164
|
+
}
|
|
165
|
+
closeDb();
|
|
166
|
+
process.exit(0);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Load env vars from tyr config directory (e.g. API keys)
|
|
171
|
+
loadEnvFile();
|
|
172
|
+
|
|
173
|
+
// Build provider pipeline based on config, applying CLI overrides
|
|
174
|
+
let config: TyrConfig;
|
|
175
|
+
try {
|
|
176
|
+
config = await readConfig();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(
|
|
179
|
+
`[tyr] invalid config: ${err instanceof Error ? err.message : err}`,
|
|
180
|
+
);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (args.providers !== undefined) {
|
|
185
|
+
const parsed = parseValue("providers", args.providers);
|
|
186
|
+
if (!parsed) {
|
|
187
|
+
console.error(`[tyr] invalid --providers value: ${args.providers}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
config.providers = parsed as TyrConfig["providers"];
|
|
192
|
+
}
|
|
193
|
+
if (args["fail-open"] !== undefined) config.failOpen = args["fail-open"];
|
|
194
|
+
if (args["claude-model"] !== undefined)
|
|
195
|
+
config.claude.model = args["claude-model"];
|
|
196
|
+
if (args["claude-timeout"] !== undefined) {
|
|
197
|
+
const t = Number(args["claude-timeout"]);
|
|
198
|
+
if (!Number.isFinite(t) || t <= 0) {
|
|
199
|
+
console.error(
|
|
200
|
+
`[tyr] invalid --claude-timeout value: ${args["claude-timeout"]}`,
|
|
201
|
+
);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
config.claude.timeout = t;
|
|
206
|
+
}
|
|
207
|
+
if (args["claude-can-deny"] !== undefined)
|
|
208
|
+
config.claude.canDeny = args["claude-can-deny"];
|
|
209
|
+
if (args["openrouter-model"] !== undefined)
|
|
210
|
+
config.openrouter.model = args["openrouter-model"];
|
|
211
|
+
if (args["openrouter-endpoint"] !== undefined)
|
|
212
|
+
config.openrouter.endpoint = args["openrouter-endpoint"];
|
|
213
|
+
if (args["openrouter-timeout"] !== undefined) {
|
|
214
|
+
const t = Number(args["openrouter-timeout"]);
|
|
215
|
+
if (!Number.isFinite(t) || t <= 0) {
|
|
216
|
+
console.error(
|
|
217
|
+
`[tyr] invalid --openrouter-timeout value: ${args["openrouter-timeout"]}`,
|
|
218
|
+
);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
config.openrouter.timeout = t;
|
|
223
|
+
}
|
|
224
|
+
if (args["openrouter-can-deny"] !== undefined)
|
|
225
|
+
config.openrouter.canDeny = args["openrouter-can-deny"];
|
|
226
|
+
if (args["verbose-log"] !== undefined)
|
|
227
|
+
config.verboseLog = args["verbose-log"];
|
|
228
|
+
|
|
229
|
+
const agent = new ClaudeAgent();
|
|
230
|
+
try {
|
|
231
|
+
await agent.init(req.cwd);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (verbose) console.error("[tyr] failed to init agent config:", err);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Build provider pipeline from config
|
|
237
|
+
const providers: Provider[] = [];
|
|
238
|
+
let cacheProvider: CacheProvider | null = null;
|
|
239
|
+
|
|
240
|
+
for (const name of resolveProviders(config)) {
|
|
241
|
+
switch (name) {
|
|
242
|
+
case "cache": {
|
|
243
|
+
const configHash = computeConfigHash(agent, config);
|
|
244
|
+
cacheProvider = new CacheProvider(configHash);
|
|
245
|
+
providers.push(cacheProvider);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "chained-commands":
|
|
249
|
+
providers.push(new ChainedCommandsProvider(agent, verbose));
|
|
250
|
+
break;
|
|
251
|
+
case "claude":
|
|
252
|
+
providers.push(new ClaudeProvider(agent, config.claude, verbose));
|
|
253
|
+
break;
|
|
254
|
+
case "openrouter":
|
|
255
|
+
providers.push(
|
|
256
|
+
new OpenRouterProvider(agent, config.openrouter, verbose),
|
|
257
|
+
);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Run pipeline
|
|
263
|
+
let result = await runPipeline(providers, req);
|
|
264
|
+
|
|
265
|
+
if (verbose) {
|
|
266
|
+
console.error(
|
|
267
|
+
`[tyr] decision=${result.decision} provider=${result.provider ?? "none"}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If all providers abstained and failOpen is enabled, allow the request
|
|
272
|
+
if (result.decision === "abstain" && config.failOpen) {
|
|
273
|
+
result = {
|
|
274
|
+
decision: "allow",
|
|
275
|
+
provider: "fail-open",
|
|
276
|
+
};
|
|
277
|
+
if (verbose) {
|
|
278
|
+
console.error("[tyr] failOpen=true, converting abstain to allow");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Write definitive results to cache (skip if result came from cache itself)
|
|
283
|
+
if (
|
|
284
|
+
cacheProvider &&
|
|
285
|
+
result.provider !== "cache" &&
|
|
286
|
+
(result.decision === "allow" || result.decision === "deny")
|
|
287
|
+
) {
|
|
288
|
+
try {
|
|
289
|
+
cacheProvider.cacheResult(
|
|
290
|
+
req,
|
|
291
|
+
result.decision,
|
|
292
|
+
result.provider ?? "unknown",
|
|
293
|
+
result.reason,
|
|
294
|
+
);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (verbose) console.error("[tyr] failed to write cache:", err);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Log the decision
|
|
301
|
+
const isCacheHit = result.provider === "cache";
|
|
302
|
+
const duration = performance.now() - startTime;
|
|
303
|
+
const toolInput = extractToolInput(req.tool_name, req.tool_input);
|
|
304
|
+
const entry: LogEntry = {
|
|
305
|
+
timestamp: Date.now(),
|
|
306
|
+
cwd: req.cwd,
|
|
307
|
+
tool_name: req.tool_name,
|
|
308
|
+
tool_input: toolInput,
|
|
309
|
+
input: JSON.stringify(req.tool_input),
|
|
310
|
+
decision: result.decision,
|
|
311
|
+
provider: result.provider ?? null,
|
|
312
|
+
reason: result.reason,
|
|
313
|
+
duration_ms: Math.round(duration),
|
|
314
|
+
session_id: req.session_id,
|
|
315
|
+
cached: isCacheHit ? 1 : 0,
|
|
316
|
+
mode: shadow ? "shadow" : undefined,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
let llm: LlmLogEntry | undefined;
|
|
320
|
+
if (config.verboseLog) {
|
|
321
|
+
if (result.provider === "claude") {
|
|
322
|
+
llm = {
|
|
323
|
+
prompt: buildPrompt(req, agent, config.claude.canDeny),
|
|
324
|
+
model: config.claude.model,
|
|
325
|
+
};
|
|
326
|
+
} else if (result.provider === "openrouter") {
|
|
327
|
+
llm = {
|
|
328
|
+
prompt: buildPrompt(req, agent, config.openrouter.canDeny),
|
|
329
|
+
model: config.openrouter.model,
|
|
330
|
+
};
|
|
331
|
+
} else {
|
|
332
|
+
// Decision came from a non-LLM provider; log the first LLM in the pipeline
|
|
333
|
+
for (const name of resolveProviders(config)) {
|
|
334
|
+
if (name === "claude") {
|
|
335
|
+
llm = {
|
|
336
|
+
prompt: buildPrompt(req, agent, config.claude.canDeny),
|
|
337
|
+
model: config.claude.model,
|
|
338
|
+
};
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
if (name === "openrouter") {
|
|
342
|
+
llm = {
|
|
343
|
+
prompt: buildPrompt(req, agent, config.openrouter.canDeny),
|
|
344
|
+
model: config.openrouter.model,
|
|
345
|
+
};
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
appendLogEntry(entry, llm);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (verbose) console.error("[tyr] failed to write log:", err);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Prune old log entries based on retention setting
|
|
359
|
+
try {
|
|
360
|
+
truncateOldLogs(config.logRetention);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (verbose) console.error("[tyr] failed to truncate logs:", err);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// In shadow mode, always abstain to Claude Code regardless of the real decision
|
|
366
|
+
if (shadow) {
|
|
367
|
+
if (verbose) {
|
|
368
|
+
console.error(
|
|
369
|
+
`[tyr] shadow mode: suppressing decision=${result.decision}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
agent.close();
|
|
373
|
+
closeDb();
|
|
374
|
+
process.exit(0);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Emit response to stdout if we have a definitive decision
|
|
379
|
+
if (result.decision === "allow" || result.decision === "deny") {
|
|
380
|
+
const decision: HookResponse["hookSpecificOutput"]["decision"] = {
|
|
381
|
+
behavior: result.decision,
|
|
382
|
+
};
|
|
383
|
+
if (result.decision === "deny" && result.reason) {
|
|
384
|
+
decision.message = result.reason;
|
|
385
|
+
}
|
|
386
|
+
const response: HookResponse = {
|
|
387
|
+
hookSpecificOutput: {
|
|
388
|
+
hookEventName: "PermissionRequest",
|
|
389
|
+
decision,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
console.log(JSON.stringify(response));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
agent.close();
|
|
396
|
+
closeDb();
|
|
397
|
+
process.exit(0);
|
|
398
|
+
},
|
|
399
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { parseTime, rejectUnknownArgs } from "../args.ts";
|
|
3
|
+
import { readConfig } from "../config.ts";
|
|
4
|
+
import { closeDb } from "../db.ts";
|
|
5
|
+
import {
|
|
6
|
+
clearLogs,
|
|
7
|
+
type LlmLogRow,
|
|
8
|
+
type LogRow,
|
|
9
|
+
readLlmLogs,
|
|
10
|
+
readLogEntries,
|
|
11
|
+
truncateOldLogs,
|
|
12
|
+
} from "../log.ts";
|
|
13
|
+
|
|
14
|
+
function formatTime(ts: number): string {
|
|
15
|
+
const d = new Date(ts);
|
|
16
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
17
|
+
const y = d.getFullYear();
|
|
18
|
+
const mo = pad(d.getMonth() + 1);
|
|
19
|
+
const da = pad(d.getDate());
|
|
20
|
+
const h = pad(d.getHours());
|
|
21
|
+
const mi = pad(d.getMinutes());
|
|
22
|
+
const se = pad(d.getSeconds());
|
|
23
|
+
return `${y}-${mo}-${da} ${h}:${mi}:${se}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatEntry(entry: LogRow): string {
|
|
27
|
+
const time = formatTime(entry.timestamp);
|
|
28
|
+
const decision = entry.decision.toUpperCase();
|
|
29
|
+
const project = entry.cwd ?? "-";
|
|
30
|
+
const tool = entry.tool_name;
|
|
31
|
+
const provider = entry.provider ?? "-";
|
|
32
|
+
const duration = `${entry.duration_ms}ms`;
|
|
33
|
+
|
|
34
|
+
const input = entry.tool_input;
|
|
35
|
+
const maxInputLen = 80;
|
|
36
|
+
const truncatedInput =
|
|
37
|
+
input.length > maxInputLen ? `${input.slice(0, maxInputLen - 1)}…` : input;
|
|
38
|
+
|
|
39
|
+
return `${time} ${decision.padEnd(10)} ${project.padEnd(30)} ${tool.padEnd(10)} ${provider.padEnd(18)} ${duration.padStart(6)} ${truncatedInput}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HEADER = `${"TIME".padEnd(21)} ${"DECISION".padEnd(10)} ${"PROJECT".padEnd(30)} ${"TOOL".padEnd(10)} ${"PROVIDER".padEnd(18)} ${"DUR".padStart(6)} INPUT`;
|
|
43
|
+
|
|
44
|
+
const logArgs = {
|
|
45
|
+
last: {
|
|
46
|
+
type: "string" as const,
|
|
47
|
+
description: "Show last N entries (default: 20)",
|
|
48
|
+
},
|
|
49
|
+
verbose: {
|
|
50
|
+
type: "boolean" as const,
|
|
51
|
+
description: "Show LLM prompt and model for entries with verbose logs",
|
|
52
|
+
},
|
|
53
|
+
json: {
|
|
54
|
+
type: "boolean" as const,
|
|
55
|
+
description: "Raw JSON output",
|
|
56
|
+
},
|
|
57
|
+
since: {
|
|
58
|
+
type: "string" as const,
|
|
59
|
+
description: "Show entries after timestamp (ISO or relative: 1h, 30m, 2d)",
|
|
60
|
+
},
|
|
61
|
+
until: {
|
|
62
|
+
type: "string" as const,
|
|
63
|
+
description: "Show entries before timestamp (ISO or relative: 1h, 30m, 2d)",
|
|
64
|
+
},
|
|
65
|
+
decision: {
|
|
66
|
+
type: "string" as const,
|
|
67
|
+
description: "Filter by decision (allow, deny, abstain, error)",
|
|
68
|
+
},
|
|
69
|
+
provider: {
|
|
70
|
+
type: "string" as const,
|
|
71
|
+
description: "Filter by provider name",
|
|
72
|
+
},
|
|
73
|
+
cwd: {
|
|
74
|
+
type: "string" as const,
|
|
75
|
+
description: "Filter by cwd path prefix",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function handleClear(): void {
|
|
80
|
+
const deleted = clearLogs();
|
|
81
|
+
console.log(`Cleared ${deleted} log entries.`);
|
|
82
|
+
closeDb();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default defineCommand({
|
|
86
|
+
meta: {
|
|
87
|
+
name: "log",
|
|
88
|
+
description:
|
|
89
|
+
"View permission check history (use 'tyr log clear' to truncate)",
|
|
90
|
+
},
|
|
91
|
+
args: logArgs,
|
|
92
|
+
async run({ args, rawArgs }) {
|
|
93
|
+
// Handle "tyr log clear" subcommand
|
|
94
|
+
if (rawArgs.includes("clear")) {
|
|
95
|
+
handleClear();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
rejectUnknownArgs(rawArgs, logArgs);
|
|
100
|
+
const last = args.last ? Number.parseInt(args.last, 10) : 20;
|
|
101
|
+
const jsonMode = args.json ?? false;
|
|
102
|
+
|
|
103
|
+
let since: number | undefined;
|
|
104
|
+
let until: number | undefined;
|
|
105
|
+
|
|
106
|
+
if (args.since) {
|
|
107
|
+
const t = parseTime(args.since);
|
|
108
|
+
if (!t) {
|
|
109
|
+
console.error(`Invalid --since value: ${args.since}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
since = t.getTime();
|
|
114
|
+
}
|
|
115
|
+
if (args.until) {
|
|
116
|
+
const t = parseTime(args.until);
|
|
117
|
+
if (!t) {
|
|
118
|
+
console.error(`Invalid --until value: ${args.until}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
until = t.getTime();
|
|
123
|
+
}
|
|
124
|
+
if (args.decision) {
|
|
125
|
+
const valid = ["allow", "deny", "abstain", "error"];
|
|
126
|
+
if (!valid.includes(args.decision)) {
|
|
127
|
+
console.error(
|
|
128
|
+
`Invalid --decision value: ${args.decision}. Must be one of: ${valid.join(", ")}`,
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Prune old log entries based on retention setting
|
|
136
|
+
try {
|
|
137
|
+
const config = await readConfig();
|
|
138
|
+
truncateOldLogs(config.logRetention);
|
|
139
|
+
} catch {
|
|
140
|
+
// Best-effort: don't fail if config is unreadable
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entries = readLogEntries({
|
|
144
|
+
last: last > 0 ? last : undefined,
|
|
145
|
+
since,
|
|
146
|
+
until,
|
|
147
|
+
decision: args.decision,
|
|
148
|
+
provider: args.provider,
|
|
149
|
+
cwd: args.cwd,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const verboseMode = args.verbose ?? false;
|
|
153
|
+
|
|
154
|
+
const llmLogs = verboseMode
|
|
155
|
+
? readLlmLogs(entries.map((e) => e.id))
|
|
156
|
+
: new Map<number, LlmLogRow>();
|
|
157
|
+
|
|
158
|
+
if (jsonMode) {
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
const llmRow = llmLogs.get(entry.id);
|
|
161
|
+
if (llmRow) {
|
|
162
|
+
const { log_id: _, ...llm } = llmRow;
|
|
163
|
+
console.log(JSON.stringify({ ...entry, llm }));
|
|
164
|
+
} else {
|
|
165
|
+
console.log(JSON.stringify(entry));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
if (entries.length === 0) {
|
|
170
|
+
console.log("No log entries yet.");
|
|
171
|
+
closeDb();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
console.log(HEADER);
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
console.log(formatEntry(entry));
|
|
177
|
+
if (verboseMode) {
|
|
178
|
+
const llm = llmLogs.get(entry.id);
|
|
179
|
+
if (llm) {
|
|
180
|
+
console.log(` model: ${llm.model}`);
|
|
181
|
+
console.log(` prompt: ${llm.prompt}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
closeDb();
|
|
188
|
+
},
|
|
189
|
+
});
|