@veda-hq/sarathi 0.0.0-stage → 0.5.6
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 +84 -2
- package/dist/bin.js +16 -0
- package/dist/cli.js +227 -0
- package/dist/config.js +151 -0
- package/dist/merge.js +132 -0
- package/dist/run.js +86 -0
- package/package.json +45 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Veda Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @veda-hq/sarathi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
सारथि, charioteer. Runs a project's Claude Code hooks from one settings entry
|
|
4
|
+
per event, with [kavach](../kavach)'s guards built in, and carries the
|
|
5
|
+
sarathi plugin's agents. Part of [Veda](https://github.com/veda-hq/veda).
|
|
6
|
+
|
|
7
|
+
## Why a package and not only a plugin
|
|
8
|
+
|
|
9
|
+
A Claude Code plugin cannot carry permissions or environment (a plugin's
|
|
10
|
+
settings apply only `agent` and `subagentStatusLine`), and a cloud session
|
|
11
|
+
does not install the plugins a repository lists, because that needs the
|
|
12
|
+
folder-trust dialog a cloud session never shows. A pinned package installs
|
|
13
|
+
wherever the repository's dependencies do: a laptop, a cloud session, a second
|
|
14
|
+
repository. So the hooks run from the package, and the plugin carries what
|
|
15
|
+
only a plugin can: the agents.
|
|
16
|
+
|
|
17
|
+
## The hooks
|
|
18
|
+
|
|
19
|
+
The project's settings keep one entry per event that runs sarathi:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"hooks": {
|
|
24
|
+
"PreToolUse": [
|
|
25
|
+
{ "matcher": "Bash|Edit|Write|NotebookEdit",
|
|
26
|
+
"hooks": [{ "type": "command", "command": "npx --no-install sarathi hook PreToolUse", "timeout": 70 }] }
|
|
27
|
+
],
|
|
28
|
+
"Stop": [
|
|
29
|
+
{ "hooks": [{ "type": "command", "command": "npx --no-install sarathi hook Stop", "timeout": 130 }] }
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
and the handlers live in `.claude/sarathi.json`, in the shape of the
|
|
36
|
+
settings' own `hooks` block, one entry per handler:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"kavach": { "config": ".claude/kavach.json", "python": "python3" },
|
|
41
|
+
"hooks": {
|
|
42
|
+
"PreToolUse": [
|
|
43
|
+
{ "matcher": "Bash|Edit|Write|NotebookEdit", "guard": "worktree" },
|
|
44
|
+
{ "matcher": "Bash", "guard": "command" },
|
|
45
|
+
{ "matcher": "Edit|Write", "command": "./scripts/check-edit.sh", "timeout": 10 }
|
|
46
|
+
],
|
|
47
|
+
"Stop": [{ "command": "./scripts/keep-going.sh", "timeout": 120 }]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Key | Meaning |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `matcher` | Claude Code's: absent or `*` for all, `A\|B` for exact names, else a regular expression |
|
|
55
|
+
| `command` | Run through `sh -c` with the event on stdin, as Claude Code runs a hook |
|
|
56
|
+
| `guard` | `worktree` or `command`: kavach's guard, run by `kavach.python` with `KAVACH_CONFIG` set to `kavach.config` |
|
|
57
|
+
| `timeout` | Seconds before the handler and its children are stopped (60) |
|
|
58
|
+
| `failClosed` | Any end but exit 0 refuses the call; always true for guards |
|
|
59
|
+
|
|
60
|
+
`sarathi hook <Event>` runs the matching handlers side by side and answers
|
|
61
|
+
Claude Code once, as Claude Code combines several hooks' answers: any exit 2
|
|
62
|
+
blocks with every blocker's reason; a fail-closed handler that crashes or
|
|
63
|
+
runs out of time blocks too; JSON answers merge with the strictest decision
|
|
64
|
+
winning (deny over ask over allow, block over none, `continue: false` over
|
|
65
|
+
true) and every handler's context kept; a handler that failed without
|
|
66
|
+
blocking is named in `systemMessage`, or on stderr with exit 1. A hook list
|
|
67
|
+
that cannot be read refuses `PreToolUse` and only warns on other events.
|
|
68
|
+
|
|
69
|
+
`sarathi check --settings .claude/settings.json` proves the settings run
|
|
70
|
+
sarathi for every event the list names, with a matcher that lets every
|
|
71
|
+
handler fire and a timeout at least the longest handler's. Run it in the
|
|
72
|
+
project's tests.
|
|
73
|
+
|
|
74
|
+
## The plugin
|
|
75
|
+
|
|
76
|
+
`sarathi@veda`, from this repository's marketplace, carries two agents:
|
|
77
|
+
`sarathi:worker` (carries out one decided task end to end) and
|
|
78
|
+
`sarathi:researcher` (answers an external question with sources, read-only).
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
claude plugin marketplace add veda-hq/veda
|
|
82
|
+
claude plugin install sarathi@veda
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Node 24 or later. MIT.
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from './cli.js';
|
|
3
|
+
const readStdin = async () => {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
for await (const chunk of process.stdin)
|
|
6
|
+
chunks.push(chunk);
|
|
7
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
8
|
+
};
|
|
9
|
+
process.exitCode = await main({
|
|
10
|
+
argv: process.argv.slice(2),
|
|
11
|
+
env: process.env,
|
|
12
|
+
cwd: process.cwd(),
|
|
13
|
+
readStdin,
|
|
14
|
+
out: text => process.stdout.write(text),
|
|
15
|
+
err: text => process.stderr.write(text),
|
|
16
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sarathi hook <Event>`: the one command a project's Claude Code settings run
|
|
3
|
+
* per event. It reads the event on stdin, runs the project's handlers for it
|
|
4
|
+
* (`.claude/sarathi.json`), and gives Claude Code their combined answer.
|
|
5
|
+
*
|
|
6
|
+
* `sarathi check`: validates the hook list, and with `--settings` that the
|
|
7
|
+
* settings run sarathi for every event it lists, with a matcher and timeout
|
|
8
|
+
* that let every handler fire.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
import { ConfigError, compileMatcher, entriesFor, loadConfig, longestTimeout, settingsMatcher } from './config.js';
|
|
13
|
+
import { merge } from './merge.js';
|
|
14
|
+
import { runAll } from './run.js';
|
|
15
|
+
export const VERSION = '0.5.6';
|
|
16
|
+
const USAGE = `usage: sarathi hook <Event> [--config <file>]
|
|
17
|
+
sarathi check [--config <file>] [--settings <file>]
|
|
18
|
+
sarathi --version
|
|
19
|
+
`;
|
|
20
|
+
/** Events where a broken hook list must refuse the call rather than let it through unguarded. */
|
|
21
|
+
const GUARDED_EVENTS = new Set(['PreToolUse']);
|
|
22
|
+
function parseOptions(args) {
|
|
23
|
+
const positional = [];
|
|
24
|
+
let config;
|
|
25
|
+
let settings;
|
|
26
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
27
|
+
const arg = args[index] ?? '';
|
|
28
|
+
if (arg === '--config' || arg === '--settings') {
|
|
29
|
+
const value = args[index + 1];
|
|
30
|
+
if (value === undefined || value === '')
|
|
31
|
+
return `${arg} needs a file`;
|
|
32
|
+
if (arg === '--config')
|
|
33
|
+
config = value;
|
|
34
|
+
else
|
|
35
|
+
settings = value;
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
else if (arg.startsWith('--')) {
|
|
39
|
+
return `unknown option ${arg}`;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
positional.push(arg);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { positional, ...(config === undefined ? {} : { config }), ...(settings === undefined ? {} : { settings }) };
|
|
46
|
+
}
|
|
47
|
+
function projectRoot(io) {
|
|
48
|
+
const dir = io.env.CLAUDE_PROJECT_DIR;
|
|
49
|
+
return dir === undefined || dir === '' ? io.cwd : dir;
|
|
50
|
+
}
|
|
51
|
+
function configPath(io, options) {
|
|
52
|
+
const root = projectRoot(io);
|
|
53
|
+
return options.config === undefined ? join(root, '.claude', 'sarathi.json') : resolve(io.cwd, options.config);
|
|
54
|
+
}
|
|
55
|
+
/** What an event's matchers are tested against: the tool, or the event's own matcher field. */
|
|
56
|
+
export function matchTarget(event) {
|
|
57
|
+
for (const key of ['tool_name', 'source', 'trigger', 'notification_type', 'agent_type']) {
|
|
58
|
+
const value = event[key];
|
|
59
|
+
if (typeof value === 'string')
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
async function hook(io, options) {
|
|
65
|
+
const event = options.positional[1];
|
|
66
|
+
if (event === undefined || options.positional.length > 2) {
|
|
67
|
+
io.err(USAGE);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const refuse = GUARDED_EVENTS.has(event);
|
|
71
|
+
const input = await io.readStdin();
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(input);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
parsed = undefined;
|
|
78
|
+
}
|
|
79
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
80
|
+
io.err(`${refuse ? 'BLOCKED: ' : ''}sarathi: the ${event} event on stdin is not a JSON object, so no handler ran.\n`);
|
|
81
|
+
return refuse ? 2 : 1;
|
|
82
|
+
}
|
|
83
|
+
let config;
|
|
84
|
+
try {
|
|
85
|
+
config = loadConfig(configPath(io, options), projectRoot(io));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const message = error instanceof ConfigError ? error.message : String(error);
|
|
89
|
+
io.err(`${refuse ? 'BLOCKED: ' : ''}sarathi cannot use its hook list: ${message}. No ${event} handler ran.\n`);
|
|
90
|
+
return refuse ? 2 : 1;
|
|
91
|
+
}
|
|
92
|
+
const entries = entriesFor(config, event, matchTarget(parsed));
|
|
93
|
+
if (entries.length === 0)
|
|
94
|
+
return 0;
|
|
95
|
+
const outcomes = await (io.run ?? runAll)(entries, config, input, io.cwd);
|
|
96
|
+
const answer = merge(event, outcomes);
|
|
97
|
+
if (answer.stdout !== '')
|
|
98
|
+
io.out(answer.stdout);
|
|
99
|
+
if (answer.stderr !== '')
|
|
100
|
+
io.err(answer.stderr);
|
|
101
|
+
return answer.code;
|
|
102
|
+
}
|
|
103
|
+
function settingsHooks(text, path) {
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return `${path} is not JSON`;
|
|
110
|
+
}
|
|
111
|
+
const found = new Map();
|
|
112
|
+
const hooks = raw?.hooks;
|
|
113
|
+
if (typeof hooks !== 'object' || hooks === null)
|
|
114
|
+
return found;
|
|
115
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
116
|
+
const list = [];
|
|
117
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
118
|
+
const { matcher, hooks: inner } = (group ?? {});
|
|
119
|
+
for (const item of Array.isArray(inner) ? inner : []) {
|
|
120
|
+
const { command, timeout } = (item ?? {});
|
|
121
|
+
if (typeof command !== 'string')
|
|
122
|
+
continue;
|
|
123
|
+
list.push({
|
|
124
|
+
matcher: typeof matcher === 'string' ? matcher : undefined,
|
|
125
|
+
command,
|
|
126
|
+
timeout: typeof timeout === 'number' ? timeout : undefined,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
found.set(event, list);
|
|
131
|
+
}
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Does a settings command run sarathi for `event`? It names sarathi (the
|
|
136
|
+
* program, or a launcher script named after it) and passes the event as a
|
|
137
|
+
* word of its own: `sarathi hook Stop`, `"$DIR"/hooks/sarathi.sh Stop`.
|
|
138
|
+
*/
|
|
139
|
+
export function runsSarathiFor(command, event) {
|
|
140
|
+
return /\bsarathi\b/.test(command) && new RegExp(`(?:^|[\\s"'])${event}(?:$|[\\s"'])`).test(command);
|
|
141
|
+
}
|
|
142
|
+
/** The settings' problems in running `config`: a missing event, a matcher that hides a handler, a timeout shorter than one. */
|
|
143
|
+
export function settingsProblems(config, settings) {
|
|
144
|
+
const problems = [];
|
|
145
|
+
for (const event of Object.keys(config.hooks)) {
|
|
146
|
+
if ((config.hooks[event] ?? []).length === 0)
|
|
147
|
+
continue;
|
|
148
|
+
const runner = (settings.get(event) ?? []).find(item => runsSarathiFor(item.command, event));
|
|
149
|
+
if (runner === undefined) {
|
|
150
|
+
problems.push(`${event}: no settings hook runs \`sarathi hook ${event}\``);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const needed = settingsMatcher(config, event);
|
|
154
|
+
const covers = compileMatcher(runner.matcher);
|
|
155
|
+
if (needed === undefined) {
|
|
156
|
+
if (runner.matcher !== undefined && runner.matcher !== '' && runner.matcher !== '*') {
|
|
157
|
+
problems.push(`${event}: a handler matches every tool, so the settings entry needs no matcher (has "${runner.matcher}")`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const hidden = needed.split('|').filter(name => name !== '' && !covers(name));
|
|
162
|
+
if (hidden.length > 0)
|
|
163
|
+
problems.push(`${event}: the settings matcher leaves out ${hidden.join(', ')}`);
|
|
164
|
+
}
|
|
165
|
+
const longest = longestTimeout(config, event);
|
|
166
|
+
if (runner.timeout === undefined || runner.timeout < longest) {
|
|
167
|
+
problems.push(`${event}: the settings timeout must be at least ${longest} s, the longest handler's (has ${runner.timeout ?? 'none'})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return problems;
|
|
171
|
+
}
|
|
172
|
+
function check(io, options) {
|
|
173
|
+
if (options.positional.length > 1) {
|
|
174
|
+
io.err(USAGE);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
let config;
|
|
178
|
+
try {
|
|
179
|
+
config = loadConfig(configPath(io, options), projectRoot(io));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
io.err(`sarathi: ${error instanceof ConfigError ? error.message : String(error)}\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
const counts = Object.entries(config.hooks).map(([event, list]) => `${event} ${list.length}`);
|
|
186
|
+
if (options.settings !== undefined) {
|
|
187
|
+
const path = resolve(io.cwd, options.settings);
|
|
188
|
+
let text;
|
|
189
|
+
try {
|
|
190
|
+
text = readFileSync(path, 'utf8');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
io.err(`sarathi: cannot read ${path}\n`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
const settings = settingsHooks(text, path);
|
|
197
|
+
if (typeof settings === 'string') {
|
|
198
|
+
io.err(`sarathi: ${settings}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const problems = settingsProblems(config, settings);
|
|
202
|
+
if (problems.length > 0) {
|
|
203
|
+
io.err(problems.map(problem => `sarathi: ${problem}\n`).join(''));
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
io.out(`sarathi: ${config.path} is valid (${counts.join(', ')})\n`);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
export async function main(io) {
|
|
211
|
+
if (io.argv.length === 1 && io.argv[0] === '--version') {
|
|
212
|
+
io.out(`${VERSION}\n`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
const options = parseOptions(io.argv);
|
|
216
|
+
if (typeof options === 'string') {
|
|
217
|
+
io.err(`sarathi: ${options}\n${USAGE}`);
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
const command = options.positional[0];
|
|
221
|
+
if (command === 'hook')
|
|
222
|
+
return hook(io, options);
|
|
223
|
+
if (command === 'check')
|
|
224
|
+
return check(io, options);
|
|
225
|
+
io.err(USAGE);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's hook list: which commands run on which Claude Code event.
|
|
3
|
+
*
|
|
4
|
+
* A project keeps one JSON file (by default `.claude/sarathi.json`) and one
|
|
5
|
+
* short entry per event in its Claude Code settings that runs
|
|
6
|
+
* `sarathi hook <Event>`. The file has the shape of the settings' own `hooks`
|
|
7
|
+
* block, flattened: each event lists entries, each entry one handler.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
11
|
+
export const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
12
|
+
const ENTRY_KEYS = new Set(['matcher', 'command', 'guard', 'timeout', 'failClosed']);
|
|
13
|
+
const TOP_KEYS = new Set(['$schema', 'kavach', 'hooks']);
|
|
14
|
+
const KAVACH_KEYS = new Set(['config', 'python']);
|
|
15
|
+
const PLAIN = /^[\w|-]+$/;
|
|
16
|
+
export class ConfigError extends Error {
|
|
17
|
+
name = 'ConfigError';
|
|
18
|
+
}
|
|
19
|
+
function isRecord(value) {
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function rejectUnknownKeys(value, known, where) {
|
|
23
|
+
const extra = Object.keys(value).filter(key => !known.has(key));
|
|
24
|
+
if (extra.length > 0)
|
|
25
|
+
throw new ConfigError(`${where}: unknown key ${extra.map(key => `"${key}"`).join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
/** Claude Code's matcher rules: absent, empty or `*` match all; plain names and `A|B` match exactly; anything else is a regular expression. */
|
|
28
|
+
export function compileMatcher(matcher, where = 'matcher') {
|
|
29
|
+
if (matcher === undefined || matcher === '' || matcher === '*')
|
|
30
|
+
return () => true;
|
|
31
|
+
if (PLAIN.test(matcher)) {
|
|
32
|
+
const names = new Set(matcher.split('|'));
|
|
33
|
+
return target => names.has(target);
|
|
34
|
+
}
|
|
35
|
+
let pattern;
|
|
36
|
+
try {
|
|
37
|
+
pattern = new RegExp(matcher);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new ConfigError(`${where}: "matcher" is not a regular expression (${error.message})`);
|
|
41
|
+
}
|
|
42
|
+
return target => pattern.test(target);
|
|
43
|
+
}
|
|
44
|
+
function parseEntry(raw, where) {
|
|
45
|
+
if (!isRecord(raw))
|
|
46
|
+
throw new ConfigError(`${where}: an entry must be an object`);
|
|
47
|
+
rejectUnknownKeys(raw, ENTRY_KEYS, where);
|
|
48
|
+
const { matcher, command, guard, timeout, failClosed } = raw;
|
|
49
|
+
if (matcher !== undefined && typeof matcher !== 'string')
|
|
50
|
+
throw new ConfigError(`${where}: "matcher" must be a string`);
|
|
51
|
+
if (command !== undefined && (typeof command !== 'string' || command.trim() === '')) {
|
|
52
|
+
throw new ConfigError(`${where}: "command" must be a non-empty string`);
|
|
53
|
+
}
|
|
54
|
+
if (guard !== undefined && guard !== 'worktree' && guard !== 'command') {
|
|
55
|
+
throw new ConfigError(`${where}: "guard" must be "worktree" or "command"`);
|
|
56
|
+
}
|
|
57
|
+
if ((command === undefined) === (guard === undefined)) {
|
|
58
|
+
throw new ConfigError(`${where}: give exactly one of "command" and "guard"`);
|
|
59
|
+
}
|
|
60
|
+
if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0)) {
|
|
61
|
+
throw new ConfigError(`${where}: "timeout" must be a positive number of seconds`);
|
|
62
|
+
}
|
|
63
|
+
if (failClosed !== undefined && typeof failClosed !== 'boolean') {
|
|
64
|
+
throw new ConfigError(`${where}: "failClosed" must be true or false`);
|
|
65
|
+
}
|
|
66
|
+
if (typeof matcher === 'string')
|
|
67
|
+
compileMatcher(matcher, where);
|
|
68
|
+
return {
|
|
69
|
+
...(typeof matcher === 'string' ? { matcher } : {}),
|
|
70
|
+
...(typeof command === 'string' ? { command } : {}),
|
|
71
|
+
...(guard === 'worktree' || guard === 'command' ? { guard } : {}),
|
|
72
|
+
...(typeof timeout === 'number' ? { timeout } : {}),
|
|
73
|
+
...(typeof failClosed === 'boolean' ? { failClosed } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Parses the file's text; `path` names it in errors, `root` is where its relative paths start. */
|
|
77
|
+
export function parseConfig(text, path, root) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(text);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw new ConfigError(`${path} is not JSON (${error.message})`);
|
|
84
|
+
}
|
|
85
|
+
if (!isRecord(raw))
|
|
86
|
+
throw new ConfigError(`${path}: the file must hold an object`);
|
|
87
|
+
rejectUnknownKeys(raw, TOP_KEYS, path);
|
|
88
|
+
const kavachRaw = raw.kavach ?? {};
|
|
89
|
+
if (!isRecord(kavachRaw))
|
|
90
|
+
throw new ConfigError(`${path}: "kavach" must be an object`);
|
|
91
|
+
rejectUnknownKeys(kavachRaw, KAVACH_KEYS, `${path} kavach`);
|
|
92
|
+
const { config, python } = kavachRaw;
|
|
93
|
+
if (config !== undefined && typeof config !== 'string')
|
|
94
|
+
throw new ConfigError(`${path}: "kavach.config" must be a path`);
|
|
95
|
+
if (python !== undefined && typeof python !== 'string')
|
|
96
|
+
throw new ConfigError(`${path}: "kavach.python" must be a program`);
|
|
97
|
+
const hooksRaw = raw.hooks;
|
|
98
|
+
if (!isRecord(hooksRaw))
|
|
99
|
+
throw new ConfigError(`${path}: "hooks" must be an object of events`);
|
|
100
|
+
const hooks = {};
|
|
101
|
+
for (const [event, list] of Object.entries(hooksRaw)) {
|
|
102
|
+
if (!Array.isArray(list))
|
|
103
|
+
throw new ConfigError(`${path} ${event}: must be a list of entries`);
|
|
104
|
+
hooks[event] = list.map((entry, index) => parseEntry(entry, `${path} ${event}[${index}]`));
|
|
105
|
+
}
|
|
106
|
+
const fromRoot = (value) => (isAbsolute(value) ? value : resolve(root, value));
|
|
107
|
+
return {
|
|
108
|
+
path,
|
|
109
|
+
root,
|
|
110
|
+
kavach: {
|
|
111
|
+
...(typeof config === 'string' ? { config: fromRoot(config) } : {}),
|
|
112
|
+
python: typeof python !== 'string' ? 'python3' : python.includes('/') ? fromRoot(python) : python,
|
|
113
|
+
},
|
|
114
|
+
hooks,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Reads the file at `path`; relative paths in it resolve from `root`. */
|
|
118
|
+
export function loadConfig(path, root) {
|
|
119
|
+
let text;
|
|
120
|
+
try {
|
|
121
|
+
text = readFileSync(path, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new ConfigError(`cannot read ${path} (${error.code ?? 'error'})`);
|
|
125
|
+
}
|
|
126
|
+
return parseConfig(text, path, root);
|
|
127
|
+
}
|
|
128
|
+
/** The entries for `event` whose matcher takes `target` (the tool name, or the event's own matcher field). */
|
|
129
|
+
export function entriesFor(config, event, target) {
|
|
130
|
+
return (config.hooks[event] ?? []).filter(entry => compileMatcher(entry.matcher)(target));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The matcher the one settings entry for `event` needs so that every entry here
|
|
134
|
+
* can fire: the union of their names, or `undefined` (all) when one entry
|
|
135
|
+
* matches all or uses a regular expression.
|
|
136
|
+
*/
|
|
137
|
+
export function settingsMatcher(config, event) {
|
|
138
|
+
const names = new Set();
|
|
139
|
+
for (const entry of config.hooks[event] ?? []) {
|
|
140
|
+
const matcher = entry.matcher;
|
|
141
|
+
if (matcher === undefined || matcher === '' || matcher === '*' || !PLAIN.test(matcher))
|
|
142
|
+
return undefined;
|
|
143
|
+
for (const name of matcher.split('|'))
|
|
144
|
+
names.add(name);
|
|
145
|
+
}
|
|
146
|
+
return [...names].sort().join('|');
|
|
147
|
+
}
|
|
148
|
+
/** The longest timeout among an event's entries, in seconds. */
|
|
149
|
+
export function longestTimeout(config, event) {
|
|
150
|
+
return Math.max(0, ...(config.hooks[event] ?? []).map(entry => entry.timeout ?? DEFAULT_TIMEOUT_SECONDS));
|
|
151
|
+
}
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Events whose plain stdout Claude Code adds to Claude's context. */
|
|
2
|
+
const CONTEXT_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
3
|
+
const PERMISSION_ORDER = ['deny', 'ask', 'allow'];
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function parseJson(text) {
|
|
8
|
+
const trimmed = text.trim();
|
|
9
|
+
if (!trimmed.startsWith('{'))
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(trimmed);
|
|
13
|
+
return isRecord(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function strings(values) {
|
|
20
|
+
return values.filter((value) => typeof value === 'string' && value.trim() !== '');
|
|
21
|
+
}
|
|
22
|
+
function joined(values) {
|
|
23
|
+
const kept = strings(values);
|
|
24
|
+
return kept.length === 0 ? undefined : kept.join('\n\n');
|
|
25
|
+
}
|
|
26
|
+
function ends(text) {
|
|
27
|
+
return text === '' || text.endsWith('\n') ? text : `${text}\n`;
|
|
28
|
+
}
|
|
29
|
+
function blocks(outcome) {
|
|
30
|
+
if (outcome.code === 2)
|
|
31
|
+
return true;
|
|
32
|
+
return outcome.failClosed && outcome.code !== 0;
|
|
33
|
+
}
|
|
34
|
+
function blockReason(outcome) {
|
|
35
|
+
if (outcome.code === 2)
|
|
36
|
+
return ends(outcome.stderr);
|
|
37
|
+
const how = outcome.timedOut ? 'ran out of time' : outcome.code === null ? 'did not finish' : `exited ${outcome.code}`;
|
|
38
|
+
return `${ends(outcome.stderr)}BLOCKED: ${outcome.label} ${how} instead of giving a verdict, so the call is refused.\n`;
|
|
39
|
+
}
|
|
40
|
+
function failureNote(outcome) {
|
|
41
|
+
const how = outcome.timedOut ? 'ran out of time' : outcome.code === null ? 'did not finish' : `exited ${outcome.code}`;
|
|
42
|
+
const detail = outcome.stderr.trim();
|
|
43
|
+
return detail === '' ? `${outcome.label} ${how}` : `${outcome.label} ${how}: ${detail}`;
|
|
44
|
+
}
|
|
45
|
+
/** Merges the hook-specific fields of several JSON answers for `event`. */
|
|
46
|
+
function mergeSpecific(event, answers, extraContext) {
|
|
47
|
+
const specifics = answers.map(answer => answer.hookSpecificOutput).filter(isRecord);
|
|
48
|
+
const merged = {};
|
|
49
|
+
for (const specific of specifics) {
|
|
50
|
+
for (const [key, value] of Object.entries(specific)) {
|
|
51
|
+
if (!(key in merged))
|
|
52
|
+
merged[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const decisions = specifics.map(specific => specific.permissionDecision).filter((value) => typeof value === 'string');
|
|
56
|
+
const winner = PERMISSION_ORDER.find(decision => decisions.includes(decision)) ?? decisions[0];
|
|
57
|
+
if (winner !== undefined) {
|
|
58
|
+
merged.permissionDecision = winner;
|
|
59
|
+
const reason = joined(specifics.filter(specific => specific.permissionDecision === winner).map(specific => specific.permissionDecisionReason));
|
|
60
|
+
if (reason === undefined)
|
|
61
|
+
delete merged.permissionDecisionReason;
|
|
62
|
+
else
|
|
63
|
+
merged.permissionDecisionReason = reason;
|
|
64
|
+
}
|
|
65
|
+
const context = joined([...specifics.map(specific => specific.additionalContext), ...extraContext]);
|
|
66
|
+
if (context === undefined)
|
|
67
|
+
delete merged.additionalContext;
|
|
68
|
+
else
|
|
69
|
+
merged.additionalContext = context;
|
|
70
|
+
if (Object.keys(merged).length === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
merged.hookEventName = event;
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
const MANAGED = new Set(['continue', 'stopReason', 'suppressOutput', 'decision', 'reason', 'systemMessage', 'hookSpecificOutput']);
|
|
76
|
+
function mergeJson(event, answers, extraContext, notes) {
|
|
77
|
+
const merged = {};
|
|
78
|
+
for (const answer of answers) {
|
|
79
|
+
for (const [key, value] of Object.entries(answer)) {
|
|
80
|
+
if (!MANAGED.has(key) && !(key in merged))
|
|
81
|
+
merged[key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (answers.some(answer => answer.continue === false)) {
|
|
85
|
+
merged.continue = false;
|
|
86
|
+
const stop = joined(answers.map(answer => answer.stopReason));
|
|
87
|
+
if (stop !== undefined)
|
|
88
|
+
merged.stopReason = stop;
|
|
89
|
+
}
|
|
90
|
+
if (answers.some(answer => answer.suppressOutput === true))
|
|
91
|
+
merged.suppressOutput = true;
|
|
92
|
+
const blockers = answers.filter(answer => answer.decision === 'block');
|
|
93
|
+
if (blockers.length > 0) {
|
|
94
|
+
merged.decision = 'block';
|
|
95
|
+
const reason = joined(blockers.map(answer => answer.reason));
|
|
96
|
+
if (reason !== undefined)
|
|
97
|
+
merged.reason = reason;
|
|
98
|
+
}
|
|
99
|
+
const message = joined([...answers.map(answer => answer.systemMessage), ...notes]);
|
|
100
|
+
if (message !== undefined)
|
|
101
|
+
merged.systemMessage = message;
|
|
102
|
+
const specific = mergeSpecific(event, answers, extraContext);
|
|
103
|
+
if (specific !== undefined)
|
|
104
|
+
merged.hookSpecificOutput = specific;
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
|
107
|
+
/** Combines the outcomes of `event`'s handlers into the one answer sarathi gives. */
|
|
108
|
+
export function merge(event, outcomes) {
|
|
109
|
+
const blockers = outcomes.filter(blocks);
|
|
110
|
+
if (blockers.length > 0)
|
|
111
|
+
return { code: 2, stdout: '', stderr: blockers.map(blockReason).join('') };
|
|
112
|
+
const failed = outcomes.filter(outcome => outcome.code !== 0);
|
|
113
|
+
const passed = outcomes.filter(outcome => outcome.code === 0);
|
|
114
|
+
const answers = [];
|
|
115
|
+
const texts = [];
|
|
116
|
+
for (const outcome of passed) {
|
|
117
|
+
const json = parseJson(outcome.stdout);
|
|
118
|
+
if (json !== undefined)
|
|
119
|
+
answers.push(json);
|
|
120
|
+
else if (outcome.stdout.trim() !== '')
|
|
121
|
+
texts.push(outcome.stdout.trim());
|
|
122
|
+
}
|
|
123
|
+
const contextTexts = CONTEXT_EVENTS.has(event) ? texts : [];
|
|
124
|
+
const notes = failed.map(failureNote);
|
|
125
|
+
if (answers.length > 0 || (failed.length > 0 && contextTexts.length > 0)) {
|
|
126
|
+
return { code: 0, stdout: `${JSON.stringify(mergeJson(event, answers, contextTexts, notes))}\n`, stderr: '' };
|
|
127
|
+
}
|
|
128
|
+
if (failed.length > 0) {
|
|
129
|
+
return { code: 1, stdout: '', stderr: failed.map(outcome => ends(failureNote(outcome))).join('') };
|
|
130
|
+
}
|
|
131
|
+
return { code: 0, stdout: texts.length === 0 ? '' : `${texts.join('\n\n')}\n`, stderr: '' };
|
|
132
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one event's handlers side by side, as Claude Code runs a settings
|
|
3
|
+
* block's hooks: each gets the event on stdin and the session's environment,
|
|
4
|
+
* and each is stopped at its own timeout.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { DEFAULT_TIMEOUT_SECONDS } from './config.js';
|
|
10
|
+
const GUARD_SCRIPTS = {
|
|
11
|
+
worktree: 'worktree_guard.py',
|
|
12
|
+
command: 'command_guard.py',
|
|
13
|
+
};
|
|
14
|
+
/** The folder holding kavach's Python guards, from this package's own dependency. */
|
|
15
|
+
export function kavachDir() {
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
return join(dirname(require.resolve('@veda-hq/kavach/package.json')), 'python', 'kavach');
|
|
18
|
+
}
|
|
19
|
+
/** How one entry runs: a command through `sh -c`, a guard through the configured Python. */
|
|
20
|
+
export function plan(entry, config, guardDir = kavachDir) {
|
|
21
|
+
const timeoutMs = (entry.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000;
|
|
22
|
+
if (entry.guard !== undefined) {
|
|
23
|
+
const env = config.kavach.config === undefined ? {} : { KAVACH_CONFIG: config.kavach.config };
|
|
24
|
+
return {
|
|
25
|
+
label: `kavach ${entry.guard} guard`,
|
|
26
|
+
file: config.kavach.python,
|
|
27
|
+
args: [join(guardDir(), GUARD_SCRIPTS[entry.guard])],
|
|
28
|
+
env,
|
|
29
|
+
timeoutMs,
|
|
30
|
+
failClosed: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const command = entry.command ?? '';
|
|
34
|
+
return { label: command, file: '/bin/sh', args: ['-c', command], env: {}, timeoutMs, failClosed: entry.failClosed === true };
|
|
35
|
+
}
|
|
36
|
+
/** Runs one plan with `input` on stdin. Never rejects: a handler that cannot start ends with code `null`. */
|
|
37
|
+
export function runPlan(step, input, cwd) {
|
|
38
|
+
return new Promise(done => {
|
|
39
|
+
const out = [];
|
|
40
|
+
const err = [];
|
|
41
|
+
let timedOut = false;
|
|
42
|
+
let settled = false;
|
|
43
|
+
const finish = (code, extra = '') => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
settled = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
done({
|
|
49
|
+
label: step.label,
|
|
50
|
+
code,
|
|
51
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
52
|
+
stderr: Buffer.concat(err).toString('utf8') + extra,
|
|
53
|
+
timedOut,
|
|
54
|
+
failClosed: step.failClosed,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const child = spawn(step.file, [...step.args], {
|
|
58
|
+
cwd,
|
|
59
|
+
env: { ...process.env, ...step.env },
|
|
60
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
61
|
+
detached: true,
|
|
62
|
+
});
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
timedOut = true;
|
|
65
|
+
try {
|
|
66
|
+
if (child.pid !== undefined)
|
|
67
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// already gone: the close event still comes
|
|
71
|
+
}
|
|
72
|
+
}, step.timeoutMs);
|
|
73
|
+
child.stdout.on('data', (chunk) => out.push(chunk));
|
|
74
|
+
child.stderr.on('data', (chunk) => err.push(chunk));
|
|
75
|
+
child.on('error', error => finish(null, `${step.label} could not start (${error.message})\n`));
|
|
76
|
+
child.on('close', code => finish(timedOut ? null : code));
|
|
77
|
+
child.stdin.on('error', () => {
|
|
78
|
+
// a handler that exits without reading its input is not a failure
|
|
79
|
+
});
|
|
80
|
+
child.stdin.end(input);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Runs every entry at once and returns their outcomes in the entries' order. */
|
|
84
|
+
export function runAll(entries, config, input, cwd) {
|
|
85
|
+
return Promise.all(entries.map(entry => runPlan(plan(entry, config), input, cwd)));
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/sarathi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.6",
|
|
4
|
+
"description": "Runs a project's Claude Code hooks from one entry per event, with kavach's guards built in, and carries the sarathi plugin's agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"coding agents",
|
|
7
|
+
"claude code",
|
|
8
|
+
"hooks",
|
|
9
|
+
"plugin",
|
|
10
|
+
"guard"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24.15"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/veda-hq/veda.git",
|
|
20
|
+
"directory": "packages/sarathi"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/veda-hq/veda/tree/main/packages/sarathi#readme",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"sarathi": "dist/bin.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"!dist/**/*.test.js"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@veda-hq/kavach": "0.5.6"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "24.19.2",
|
|
39
|
+
"typescript": "7.0.2"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
44
|
+
"test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts --test-coverage-exclude=src/bin.ts src/**/*.test.ts",
|
|
45
|
+
"smoke": "node dist/bin.js --version && node dist/bin.js check --config test/fixture/sarathi.json --settings test/fixture/settings.json",
|
|
46
|
+
"check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
|
|
47
|
+
}
|
|
6
48
|
}
|