assent-cli 0.0.0-stage → 0.6.1
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 +157 -2
- package/dist/args.d.ts +29 -0
- package/dist/args.js +103 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +45 -0
- package/dist/claude-code.d.ts +19 -0
- package/dist/claude-code.js +144 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +636 -0
- package/dist/client.d.ts +224 -0
- package/dist/client.js +112 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +98 -0
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +46 -0
- package/dist/exit-codes.d.ts +19 -0
- package/dist/exit-codes.js +27 -0
- package/dist/hook-ask.d.ts +39 -0
- package/dist/hook-ask.js +241 -0
- package/dist/hook.d.ts +20 -0
- package/dist/hook.js +177 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +24 -0
- package/dist/mcp-bridge.d.ts +11 -0
- package/dist/mcp-bridge.js +100 -0
- package/dist/redact.d.ts +2 -0
- package/dist/redact.js +32 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +12 -0
- package/dist/usage.d.ts +5 -0
- package/dist/usage.js +90 -0
- package/package.json +22 -4
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
#
|
|
1
|
+
# assent-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The command line agents use to talk to Assent: ask the person a question and wait for the answer, record runs, write notes and tasks, take shared locks, check standing rules and hand work to other agents. Every command prints one JSON line; `--get <field>` prints a single field, for shell scripts.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Needs Node.js 20 or later.
|
|
8
|
+
|
|
9
|
+
npm install -g assent-cli
|
|
10
|
+
assent --version
|
|
11
|
+
|
|
12
|
+
## Configure
|
|
13
|
+
|
|
14
|
+
Create an agent in Assent (Agents), and copy its agent key (it starts with `oak_`; Assent shows it once). Then either set it in the environment:
|
|
15
|
+
|
|
16
|
+
export ASSENT_AGENT_KEY=oak_...
|
|
17
|
+
|
|
18
|
+
or write it to `~/.assent/config.json` (mode 600):
|
|
19
|
+
|
|
20
|
+
{ "agentKey": "oak_..." }
|
|
21
|
+
|
|
22
|
+
Requests go to the hosted Assent service unless you set `ASSENT_API_URL` (or `apiUrl` in the config file). The URL must use https (plain http only for localhost), and redirects are refused, so the agent key is never sent in the clear or to another host.
|
|
23
|
+
|
|
24
|
+
| Setting | What it does |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `ASSENT_AGENT_KEY` | The agent key. Overrides `agentKey` in the config file. |
|
|
27
|
+
| `ASSENT_API_URL` | The Assent API to talk to. Overrides `apiUrl` in the config file. Default `https://ordia-api.specvista.com/api/v1`. |
|
|
28
|
+
| `ASSENT_CONFIG` | Path of the config file to read instead of `~/.assent/config.json`, for example one file per agent. |
|
|
29
|
+
| `ASSENT_TIMEOUT_MS` | Request timeout in milliseconds. Default 10000. |
|
|
30
|
+
| `ASSENT_RUN_UUID` | Run that `ask`, `task add`, `note` and `handoff` link their new item to when `--run` is not given. |
|
|
31
|
+
| `ASSENT_HOOK_TIMEOUT_MS` | Timeout for each request `assent hook pre-tool-use` makes. Default 3000. |
|
|
32
|
+
| `ASSENT_HOOK_WAIT_SECONDS` | How long `assent hook pre-tool-use --ask` waits for the person. Overrides `hook.waitSeconds` in the config file. Default 120. |
|
|
33
|
+
|
|
34
|
+
## Examples
|
|
35
|
+
|
|
36
|
+
RUN=$(assent run start --summary "job scan" --get uuid)
|
|
37
|
+
assent run progress "$RUN" --done 3 --total 7 --label "Race entries"
|
|
38
|
+
assent run finish "$RUN" --exit-code 0 --log-file run.log
|
|
39
|
+
|
|
40
|
+
assent ask --title "Submit 2 applications" --item "01:Acme, Staff Engineer" --action "submit:Submit selected:primary" --action "skip:Skip all" --dedupe jobs:2026-09-29
|
|
41
|
+
assent ask --kind pay --title "Pay: Leeds Half" --url https://example.com/entry --due "2026-10-20 18:00"
|
|
42
|
+
assent ask --kind reply --title "Reply to Ada" --draft-file ./reply.txt --dedupe reply:ada:42
|
|
43
|
+
assent ask --title "Deploy now?" --wait --timeout 600
|
|
44
|
+
assent answers
|
|
45
|
+
assent picked-up "$TASK"
|
|
46
|
+
|
|
47
|
+
assent attach ./report.pdf --get uuid
|
|
48
|
+
assent ask --title "Review the report" --item "r1:Quarterly report" --attach r1:./report.pdf
|
|
49
|
+
|
|
50
|
+
assent task add --title "Renew the iOS certificate" --why "It expires on Friday" --due 2026-10-15T17:00 --remind "2026-10-15 09:30"
|
|
51
|
+
assent task update "$TASK" --due 2026-10-16T12:00 --remind none
|
|
52
|
+
assent note --title "Scan results" --content-file ./results.md --tag jobs
|
|
53
|
+
|
|
54
|
+
assent lock acquire deploy:web --ttl 45 --wait 90 && deploy; assent lock release deploy:web
|
|
55
|
+
assent handoff --to "Release checker" --title "3 builds to verify" --detail ./builds.csv
|
|
56
|
+
|
|
57
|
+
Run `assent help` for every command and option.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `whoami` | Show the agent this key belongs to. |
|
|
64
|
+
| `run start`, `run progress`, `run finish`, `run defer` | Record a run, show its progress on the phone, finish it with an exit code or status (optionally with a redacted log tail), or show it as deferred until a retry time. |
|
|
65
|
+
| `ask` | Ask the person: approve, todo, pay, login, code or reply. Items, actions, fields, badges, links, attachments, grouping into one decision card, due and reminder times, `--dedupe`, and `--wait` to block until answered. |
|
|
66
|
+
| `attach` | Upload a file (pdf, png, jpeg, gif, webp, txt, md; at most 10 MB) and print its uuid, once the owner turns attachments on for this agent. |
|
|
67
|
+
| `answers`, `picked-up` | List answered asks not picked up yet, and mark one as acted on. |
|
|
68
|
+
| `task add`, `task update`, `task resolve`, `task get`, `task item-result`, `task item-status` | Create and manage tasks, and report per-item outcomes as they finish. `task add --parent <ask-uuid> --parent-item <item-id>` links a to-do to one item of your own ask, so finishing either one finishes both. |
|
|
69
|
+
| `note`, `note update` | Write or update a note. |
|
|
70
|
+
| `link` | Link two items (note, task, run, agent). |
|
|
71
|
+
| `memory get` | Pinned memory notes shared with this agent. |
|
|
72
|
+
| `lock acquire`, `lock release` | Shared locks between agents. |
|
|
73
|
+
| `handoff`, `handoffs`, `handoffs done` | Hand work to another agent and work through hand-offs given to you. |
|
|
74
|
+
| `project get`, `project overview` | Read a project the owner linked this agent to. |
|
|
75
|
+
| `plan create`, `plan get`, `plan sessions` | Create a plan of scheduled sessions and update them. |
|
|
76
|
+
| `rules check`, `rules list`, `rules add --deny`, `rules remove` | Check the person's standing rules before acting, and add deny rules for this agent. |
|
|
77
|
+
| `hook pre-tool-use [--ask]` | Claude Code PreToolUse hook backed by the person's standing rules; with `--ask`, risky calls wait for the person in Assent. |
|
|
78
|
+
| `hook claude-code install`, `hook claude-code uninstall` | Add or remove that hook in Claude Code settings, showing the change first. |
|
|
79
|
+
| `mcp` | A stdio MCP server that forwards every message to the Assent MCP endpoint with this agent key. |
|
|
80
|
+
|
|
81
|
+
Due and reminder times take a date and time, `2026-10-15T17:00` or `2026-10-15 17:00` (this computer's time zone), or with an offset such as `2026-10-15T17:00:00+01:00`. A bare date such as `2026-10-15` means 09:00 that day in your Assent time zone, and prints a warning on stderr. `--remind` sets when the reminder push goes out: for a to-do (`task add`, or `ask --kind todo`) it is sent at that exact moment, even in quiet hours; for other asks a reminder that falls in quiet hours waits until they end. Without `--remind`, Assent reminds at the due time.
|
|
82
|
+
|
|
83
|
+
A reply ask carries a draft message (`--draft text`, or `--draft-file path`, or `--draft-file -` for stdin; at most 4000 characters). The person can edit it in Assent and taps Send or Skip; Send answers with `answer.value`, the final text to send.
|
|
84
|
+
|
|
85
|
+
## Exit codes
|
|
86
|
+
|
|
87
|
+
| Code | Meaning |
|
|
88
|
+
|---|---|
|
|
89
|
+
| 0 | OK |
|
|
90
|
+
| 1 | Error |
|
|
91
|
+
| 2 | Usage error |
|
|
92
|
+
| 3 | Conflict: lock held, already answered or expired |
|
|
93
|
+
| 4 | Not found |
|
|
94
|
+
| 5 | Not configured or unauthorised |
|
|
95
|
+
| 6 | `--wait` timed out |
|
|
96
|
+
| 10 | `rules check` found a deny rule |
|
|
97
|
+
| 11 | `rules check` found no rule: ask the person |
|
|
98
|
+
| 69 | Assent unreachable: network failure, timeout, rate limit or a 5xx |
|
|
99
|
+
|
|
100
|
+
Scripts should treat anything other than 0 (and 3 for locks) as "Assent unavailable, carry on as before".
|
|
101
|
+
|
|
102
|
+
## Standing rules
|
|
103
|
+
|
|
104
|
+
The person can answer an approve ask with Always or Never (for 1 day, 7 days, 30 days or with no expiry). That creates a standing rule for the agent that asked and the ask's action. Name what the ask is about so the rule is reusable:
|
|
105
|
+
|
|
106
|
+
assent ask --title "Deploy the web app" --rule-action deploy:web --target production --action "go:Deploy:primary" --action "hold:Not now" --wait
|
|
107
|
+
|
|
108
|
+
A later ask from the same agent with a matching action and target comes back already answered, so `--wait` returns at once. To decide before asking at all:
|
|
109
|
+
|
|
110
|
+
assent rules check --action post --target linkedin --summary "Post the weekly update"
|
|
111
|
+
case $? in 0) post_it ;; 10) echo "not allowed" ;; 11) assent ask --title "Post the weekly update?" --rule-action post --target linkedin ;; *) echo "Assent unavailable" ;; esac
|
|
112
|
+
|
|
113
|
+
Deny always beats allow; otherwise the most specific rule wins (this agent over every agent, a target over none, the longer pattern). Patterns use `*` as a wildcard and ignore case. Agents can only add deny rules for themselves (`rules add --deny --action Bash --target "git push*" --reason "Pushing deploys" --expires 30d`); allow rules come only from the person.
|
|
114
|
+
|
|
115
|
+
## Claude Code hook
|
|
116
|
+
|
|
117
|
+
`assent hook pre-tool-use` reads a Claude Code PreToolUse event on stdin, checks the tool call against the person's standing rules and prints the hook decision. The action is the tool name (`Bash`, `Edit`, `Write`, `WebFetch`, an MCP tool name and so on) and the target is the command, file path, URL or query.
|
|
118
|
+
|
|
119
|
+
An allow is only honoured when the target is exactly what will run: if a secret had to be redacted, the target is longer than 4000 characters, or a shell command is anything but one simple command (any of `;`, `&`, `|`, `$`, a backtick, `<`, `>`, `(`, `)` or a line break), the hook answers ask instead. A deny is always honoured. The hook fails open to ask: a network error, a timeout, a rate limit, a 5xx, missing config or an unreadable event all answer ask, and it always exits 0.
|
|
120
|
+
|
|
121
|
+
Add it to `~/.claude/settings.json` (every project) or `.claude/settings.json` (one project), with the key of an agent you created for this assistant:
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
"hooks": {
|
|
125
|
+
"PreToolUse": [
|
|
126
|
+
{
|
|
127
|
+
"matcher": "Bash|Edit|Write|MultiEdit|WebFetch|mcp__.*",
|
|
128
|
+
"hooks": [{ "type": "command", "command": "ASSENT_CONFIG=$HOME/.assent/claude-code.json assent hook pre-tool-use", "timeout": 10 }]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Try it by hand:
|
|
135
|
+
|
|
136
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | assent hook pre-tool-use
|
|
137
|
+
|
|
138
|
+
### Asking the person before risky calls
|
|
139
|
+
|
|
140
|
+
With `--ask`, a call on the ask list goes to the person instead of Claude Code's own prompt. This is a best-effort tripwire, not a sandbox: a command written to dodge the patterns can get past it. Standing rules still come first (deny blocks, an exact allow runs). Otherwise the hook sends an approve ask (Allow or Block) and waits up to `hook.waitSeconds` (default 120), never longer than `--max-wait` (45 seconds when it is not given, inside Claude Code's default hook timeout). Each ask carries a fresh `Idempotency-Key` and `allowDuplicate`, and the hook blocks if Assent hands back a deduped or different ask. The ask detail shows the matched part, the whole command (secrets redacted, hidden and control characters shown as a replacement character) and the working directory; a call longer than 4000 characters is blocked without an ask, and a shell command over 64 KB is blocked before it is even matched. A failed poll withdraws the ask. Allow runs the call; Block, no answer in time (the ask is then withdrawn), an expired or closed ask, Assent being unreachable, no agent key or invalid hook settings all block it. Calls not on the list behave as without `--ask`. An ask for one simple command of at most 500 characters carries the tool and command as its action and target, so Always and Never make reusable rules; any other ask is one-off, with no Always or Never.
|
|
141
|
+
|
|
142
|
+
The default list is Bash commands matching `git push*`, `git reset --hard*`, `rm -r*`, `rm -fr*`, `*deploy*`, `npm publish*`, `terraform apply*`, `terraform destroy*`, `kubectl delete*` or `docker push*`, checked against the whole command and against every word-tail of each simple command in it, with and without quotes and backslashes, and again after dropping command paths (`/usr/bin/git`), git global options (`-C dir`, `-c k=v`, `--no-pager`) and normalising recursive `rm` flags (`-R`, `-f -r`, `--recursive`). Braces, subshells, `bash -c`, `eval` and shell keywords are covered by the word-tails. Replace it in the config file:
|
|
143
|
+
|
|
144
|
+
{ "agentKey": "oak_...", "hook": { "ask": [{ "tool": "Bash", "target": "git push*" }, { "tool": "mcp__github__*" }], "waitSeconds": 300 } }
|
|
145
|
+
|
|
146
|
+
`assent hook claude-code install [--config path] [--settings path] [--yes]` writes the hook into `~/.claude/settings.json` (or `--settings`), as `assent hook pre-tool-use --ask --max-wait <waitSeconds>` with `ASSENT_CONFIG` set to the absolute `--config` path when given, and a Claude Code timeout 30 seconds above the wait. Run it again after raising `waitSeconds`. The settings file is written atomically (a temporary file renamed into place, through a symlink to its target, keeping its mode). It prints the diff on stderr and writes only after you confirm (or with `--yes`). It replaces any earlier Assent hook rather than adding a second. `assent hook claude-code uninstall` removes every Assent hook and leaves the rest of the settings untouched.
|
|
147
|
+
|
|
148
|
+
## MCP over stdio
|
|
149
|
+
|
|
150
|
+
`assent mcp` reads MCP JSON-RPC messages, one per line, on stdin, posts each to `<api>/api/v1/mcp` with the agent key, and prints each reply on one line. Requests run side by side, each with a 90 second limit. HTTP and network failures come back as JSON-RPC errors for that request. Without an agent key it exits 5. Use it for MCP clients that start servers as commands:
|
|
151
|
+
|
|
152
|
+
{ "mcpServers": { "assent": { "command": "assent", "args": ["mcp"], "env": { "ASSENT_CONFIG": "/path/to/agent.json" } } } }
|
|
153
|
+
|
|
154
|
+
The package also installs the command as `assent-cli`, so `npx assent-cli mcp` works once it is published.
|
|
155
|
+
|
|
156
|
+
## Safety
|
|
157
|
+
|
|
158
|
+
`run finish` redacts emails, tokens, keys, phone numbers and home paths from the summary and log tail before sending. `note` and `note update` redact the title and content the same way, and `ask` and `task add` redact the title and detail (a `--url` link and a reply draft are sent as given). The hook redacts secrets from the target before it is sent. Never pass passwords, card or bank details to any command; `pay` and `login` asks carry only a link.
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type OptionSpec = Record<string, {
|
|
2
|
+
type: "string" | "boolean";
|
|
3
|
+
multiple?: boolean;
|
|
4
|
+
}>;
|
|
5
|
+
export interface Parsed {
|
|
6
|
+
values: Record<string, string | boolean | string[] | undefined>;
|
|
7
|
+
positionals: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function parseCommandArgs(argv: string[], options: OptionSpec): Parsed;
|
|
10
|
+
export declare function parseField(raw: string): Record<string, unknown>;
|
|
11
|
+
export declare function parseBadge(raw: string): {
|
|
12
|
+
label: string;
|
|
13
|
+
tone?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function parseLink(raw: string): {
|
|
16
|
+
label: string;
|
|
17
|
+
url: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function readJsonInput(source: string, flag: string, readFile: (path: string) => string | null, stdin: () => Promise<string>): Promise<unknown>;
|
|
20
|
+
export declare function readItemsJson(source: string, readFile: (path: string) => string | null, stdin: () => Promise<string>): Promise<Array<Record<string, unknown>>>;
|
|
21
|
+
export declare function parseGroup(raw: string): {
|
|
22
|
+
key: string;
|
|
23
|
+
title: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function parseRules(conflictOn: string[], totalOf: string | undefined, declineAction: string | undefined): {
|
|
26
|
+
conflictOn?: string[];
|
|
27
|
+
totalOf?: string;
|
|
28
|
+
declineAction?: string;
|
|
29
|
+
} | undefined;
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseCommandArgs = parseCommandArgs;
|
|
4
|
+
exports.parseField = parseField;
|
|
5
|
+
exports.parseBadge = parseBadge;
|
|
6
|
+
exports.parseLink = parseLink;
|
|
7
|
+
exports.readJsonInput = readJsonInput;
|
|
8
|
+
exports.readItemsJson = readItemsJson;
|
|
9
|
+
exports.parseGroup = parseGroup;
|
|
10
|
+
exports.parseRules = parseRules;
|
|
11
|
+
const node_util_1 = require("node:util");
|
|
12
|
+
const exit_codes_1 = require("./exit-codes");
|
|
13
|
+
function parseCommandArgs(argv, options) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = (0, node_util_1.parseArgs)({ args: argv, options: { ...options, get: { type: "string" } }, allowPositionals: true, strict: true });
|
|
16
|
+
return { values: parsed.values, positionals: parsed.positionals };
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, err.message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const FIELD_TYPES = ["text", "money", "date", "datetime", "number", "score"];
|
|
23
|
+
const BADGE_TONES = ["neutral", "ok", "warn", "danger", "note"];
|
|
24
|
+
const MONEY_VALUE = /^([A-Z]{3})\s*(-?\d+(?:\.\d+)?)$/;
|
|
25
|
+
const SCORE_VALUE = /^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/;
|
|
26
|
+
function parseField(raw) {
|
|
27
|
+
const [key, label, ...rest] = raw.split(":");
|
|
28
|
+
const typed = rest.length > 1 && FIELD_TYPES.includes(rest[0]);
|
|
29
|
+
const type = typed ? rest[0] : undefined;
|
|
30
|
+
const value = (typed ? rest.slice(1) : rest).join(":");
|
|
31
|
+
if (!key || !label || !value)
|
|
32
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--field must look like key:label:value or key:label:type:value");
|
|
33
|
+
const field = { key, label, ...(type ? { type } : {}), value };
|
|
34
|
+
const money = type === "money" ? MONEY_VALUE.exec(value) : null;
|
|
35
|
+
if (money)
|
|
36
|
+
Object.assign(field, { amount: Number(money[2]), currency: money[1] });
|
|
37
|
+
if (type === "date" || type === "datetime")
|
|
38
|
+
field.date = value;
|
|
39
|
+
if (type === "number") {
|
|
40
|
+
if (!Number.isFinite(Number(value)))
|
|
41
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--field number values must be numeric");
|
|
42
|
+
field.number = Number(value);
|
|
43
|
+
}
|
|
44
|
+
const score = type === "score" ? SCORE_VALUE.exec(value) : null;
|
|
45
|
+
if (score)
|
|
46
|
+
Object.assign(field, { number: Number(score[1]), max: Number(score[2]) });
|
|
47
|
+
return field;
|
|
48
|
+
}
|
|
49
|
+
function parseBadge(raw) {
|
|
50
|
+
const at = raw.lastIndexOf(":");
|
|
51
|
+
const tone = at > 0 ? raw.slice(at + 1) : "";
|
|
52
|
+
return BADGE_TONES.includes(tone) ? { label: raw.slice(0, at), tone } : { label: raw };
|
|
53
|
+
}
|
|
54
|
+
function parseLink(raw) {
|
|
55
|
+
const at = raw.indexOf("=");
|
|
56
|
+
const label = at > 0 ? raw.slice(0, at) : "";
|
|
57
|
+
const url = at > 0 ? raw.slice(at + 1) : "";
|
|
58
|
+
if (!label || !/^https?:\/\//.test(url))
|
|
59
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--link must look like label=https://...");
|
|
60
|
+
return { label, url };
|
|
61
|
+
}
|
|
62
|
+
async function readJsonInput(source, flag, readFile, stdin) {
|
|
63
|
+
const raw = source === "-" ? await stdin() : readFile(source);
|
|
64
|
+
if (raw === null)
|
|
65
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Cannot read ${source}`);
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${flag} is not valid JSON`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function readItemsJson(source, readFile, stdin) {
|
|
74
|
+
const parsed = await readJsonInput(source, "item-json", readFile, stdin);
|
|
75
|
+
if (!Array.isArray(parsed))
|
|
76
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--item-json must be a JSON array of items");
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
const GROUP_KEY = /^[A-Za-z0-9._-]{1,80}$/;
|
|
80
|
+
const RULE_FIELD_KEY = /^[a-z0-9_]{1,40}$/;
|
|
81
|
+
const MAX_CONFLICT_FIELDS = 4;
|
|
82
|
+
const MAX_GROUP_TITLE = 120;
|
|
83
|
+
const MAX_ACTION_ID = 60;
|
|
84
|
+
function parseGroup(raw) {
|
|
85
|
+
const at = raw.indexOf(":");
|
|
86
|
+
const key = at > 0 ? raw.slice(0, at) : "";
|
|
87
|
+
const title = at > 0 ? raw.slice(at + 1).trim() : "";
|
|
88
|
+
if (!GROUP_KEY.test(key) || !title || title.length > MAX_GROUP_TITLE)
|
|
89
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--group must look like key:title, with a key of letters, digits, . _ - (at most 80) and a title of at most 120 characters");
|
|
90
|
+
return { key, title };
|
|
91
|
+
}
|
|
92
|
+
function parseRules(conflictOn, totalOf, declineAction) {
|
|
93
|
+
if (conflictOn.length > MAX_CONFLICT_FIELDS)
|
|
94
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--conflict-on can be given at most ${MAX_CONFLICT_FIELDS} times`);
|
|
95
|
+
for (const key of [...conflictOn, ...(totalOf === undefined ? [] : [totalOf])]) {
|
|
96
|
+
if (!RULE_FIELD_KEY.test(key))
|
|
97
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--conflict-on and --total-of take a field key of lowercase letters, digits and _, got ${key}`);
|
|
98
|
+
}
|
|
99
|
+
if (declineAction !== undefined && (declineAction.length < 1 || declineAction.length > MAX_ACTION_ID))
|
|
100
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--decline-action must be an action id of 1 to 60 characters");
|
|
101
|
+
const rules = { ...(conflictOn.length ? { conflictOn } : {}), ...(totalOf === undefined ? {} : { totalOf }), ...(declineAction === undefined ? {} : { declineAction }) };
|
|
102
|
+
return Object.keys(rules).length ? rules : undefined;
|
|
103
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_os_1 = require("node:os");
|
|
6
|
+
const node_readline_1 = require("node:readline");
|
|
7
|
+
const promises_1 = require("node:readline/promises");
|
|
8
|
+
const cli_1 = require("./cli");
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
async function confirm(question) {
|
|
11
|
+
if (!process.stdin.isTTY)
|
|
12
|
+
return false;
|
|
13
|
+
const prompt = (0, promises_1.createInterface)({ input: process.stdin, output: process.stderr });
|
|
14
|
+
try {
|
|
15
|
+
return /^y(es)?$/i.test((await prompt.question(question)).trim());
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
prompt.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function readStdin() {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of process.stdin)
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
26
|
+
}
|
|
27
|
+
(0, cli_1.runCli)(process.argv.slice(2), {
|
|
28
|
+
env: process.env,
|
|
29
|
+
home: (0, node_os_1.homedir)(),
|
|
30
|
+
readFile: config_1.readFileOrNull,
|
|
31
|
+
readBytes: config_1.readBytesOrNull,
|
|
32
|
+
writeFile: config_1.writeFileAtomic,
|
|
33
|
+
removeFile: (path) => (0, node_fs_1.rmSync)(path, { force: true }),
|
|
34
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
35
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
36
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
37
|
+
now: () => Date.now(),
|
|
38
|
+
readStdin,
|
|
39
|
+
stdinLines: () => (0, node_readline_1.createInterface)({ input: process.stdin, crlfDelay: Infinity }),
|
|
40
|
+
confirm,
|
|
41
|
+
cwd: () => process.cwd(),
|
|
42
|
+
name: (0, cli_1.commandName)(process.argv[1]),
|
|
43
|
+
}).then((code) => {
|
|
44
|
+
process.exitCode = code;
|
|
45
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SetupDeps {
|
|
2
|
+
env: NodeJS.ProcessEnv;
|
|
3
|
+
home: string;
|
|
4
|
+
readFile(path: string): string | null;
|
|
5
|
+
writeFile(path: string, content: string): void;
|
|
6
|
+
stdout(line: string): void;
|
|
7
|
+
stderr(line: string): void;
|
|
8
|
+
confirm(question: string): Promise<boolean>;
|
|
9
|
+
cwd(): string;
|
|
10
|
+
}
|
|
11
|
+
type Json = Record<string, unknown>;
|
|
12
|
+
export declare const HOOK_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|WebFetch|mcp__.*";
|
|
13
|
+
export declare function shellQuote(value: string): string;
|
|
14
|
+
export declare function hookCommand(waitSeconds: number, configPath?: string): string;
|
|
15
|
+
export declare function absoluteConfigPath(path: string, home: string, cwd: string): string;
|
|
16
|
+
export declare function installHook(settings: Json, command: string, timeoutSeconds: number): Json;
|
|
17
|
+
export declare function uninstallHook(settings: Json): Json;
|
|
18
|
+
export declare function runClaudeCodeSetup(argv: string[], deps: SetupDeps): Promise<number>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HOOK_MATCHER = void 0;
|
|
4
|
+
exports.shellQuote = shellQuote;
|
|
5
|
+
exports.hookCommand = hookCommand;
|
|
6
|
+
exports.absoluteConfigPath = absoluteConfigPath;
|
|
7
|
+
exports.installHook = installHook;
|
|
8
|
+
exports.uninstallHook = uninstallHook;
|
|
9
|
+
exports.runClaudeCodeSetup = runClaudeCodeSetup;
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const args_1 = require("./args");
|
|
12
|
+
const config_1 = require("./config");
|
|
13
|
+
const diff_1 = require("./diff");
|
|
14
|
+
const exit_codes_1 = require("./exit-codes");
|
|
15
|
+
const hook_ask_1 = require("./hook-ask");
|
|
16
|
+
exports.HOOK_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|WebFetch|mcp__.*";
|
|
17
|
+
const ASSENT_HOOK = /(?:^|[\s/])(?:assent|ordia) hook pre-tool-use(?:\s|$)/;
|
|
18
|
+
const TIMEOUT_MARGIN_SECONDS = 30;
|
|
19
|
+
const SAFE_WORD = /^[A-Za-z0-9_./~:@%+=,-]+$/;
|
|
20
|
+
const OPTIONS = { settings: { type: "string" }, config: { type: "string" }, yes: { type: "boolean" } };
|
|
21
|
+
function isObject(value) {
|
|
22
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function shellQuote(value) {
|
|
25
|
+
return SAFE_WORD.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
26
|
+
}
|
|
27
|
+
function hookCommand(waitSeconds, configPath) {
|
|
28
|
+
return `${configPath ? `ASSENT_CONFIG=${shellQuote(configPath)} ` : ""}assent hook pre-tool-use --ask --max-wait ${waitSeconds}`;
|
|
29
|
+
}
|
|
30
|
+
function absoluteConfigPath(path, home, cwd) {
|
|
31
|
+
const expanded = path === "~" ? home : path.startsWith("~/") ? (0, node_path_1.join)(home, path.slice(2)) : path;
|
|
32
|
+
return (0, node_path_1.resolve)(cwd, expanded);
|
|
33
|
+
}
|
|
34
|
+
function isAssentHook(hook) {
|
|
35
|
+
return isObject(hook) && typeof hook.command === "string" && ASSENT_HOOK.test(hook.command);
|
|
36
|
+
}
|
|
37
|
+
function withoutAssent(groups) {
|
|
38
|
+
return groups.flatMap((group) => {
|
|
39
|
+
if (!isObject(group) || !Array.isArray(group.hooks))
|
|
40
|
+
return [group];
|
|
41
|
+
const hooks = group.hooks.filter((hook) => !isAssentHook(hook));
|
|
42
|
+
if (hooks.length === group.hooks.length)
|
|
43
|
+
return [group];
|
|
44
|
+
return hooks.length ? [{ ...group, hooks }] : [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function hooksOf(settings) {
|
|
48
|
+
const hooks = settings.hooks ?? {};
|
|
49
|
+
if (!isObject(hooks))
|
|
50
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "The settings \"hooks\" value is not an object, so nothing was changed");
|
|
51
|
+
const preToolUse = hooks.PreToolUse ?? [];
|
|
52
|
+
if (!Array.isArray(preToolUse))
|
|
53
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "The settings \"hooks.PreToolUse\" value is not a list, so nothing was changed");
|
|
54
|
+
return { hooks, preToolUse };
|
|
55
|
+
}
|
|
56
|
+
function installHook(settings, command, timeoutSeconds) {
|
|
57
|
+
const { hooks, preToolUse } = hooksOf(settings);
|
|
58
|
+
const entry = { matcher: exports.HOOK_MATCHER, hooks: [{ type: "command", command, timeout: timeoutSeconds }] };
|
|
59
|
+
return { ...settings, hooks: { ...hooks, PreToolUse: [...withoutAssent(preToolUse), entry] } };
|
|
60
|
+
}
|
|
61
|
+
function uninstallHook(settings) {
|
|
62
|
+
const { hooks, preToolUse } = hooksOf(settings);
|
|
63
|
+
const remaining = withoutAssent(preToolUse);
|
|
64
|
+
if (remaining.length === preToolUse.length)
|
|
65
|
+
return settings;
|
|
66
|
+
const nextHooks = { ...hooks, PreToolUse: remaining };
|
|
67
|
+
if (!remaining.length)
|
|
68
|
+
delete nextHooks.PreToolUse;
|
|
69
|
+
const next = { ...settings, hooks: nextHooks };
|
|
70
|
+
if (!Object.keys(nextHooks).length)
|
|
71
|
+
delete next.hooks;
|
|
72
|
+
return next;
|
|
73
|
+
}
|
|
74
|
+
function parseSettings(raw, path) {
|
|
75
|
+
if (raw === null || !raw.trim())
|
|
76
|
+
return {};
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `${path} is not valid JSON, so nothing was changed`);
|
|
83
|
+
}
|
|
84
|
+
if (!isObject(parsed))
|
|
85
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `${path} does not hold a JSON object, so nothing was changed`);
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
function indentOf(raw) {
|
|
89
|
+
return (raw === null ? undefined : /^([ \t]+)"/m.exec(raw)?.[1]) ?? " ";
|
|
90
|
+
}
|
|
91
|
+
function sources(deps, configPath) {
|
|
92
|
+
const env = configPath ? { ...deps.env, ASSENT_CONFIG: configPath } : deps.env;
|
|
93
|
+
return { env, home: deps.home, readFile: deps.readFile, exists: (path) => deps.readFile(path) !== null };
|
|
94
|
+
}
|
|
95
|
+
function waitSeconds(deps, configPath) {
|
|
96
|
+
const src = sources(deps, configPath);
|
|
97
|
+
try {
|
|
98
|
+
(0, config_1.loadCliConfig)(src);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
deps.stderr(`Warning: ${err.message}. Until the hook has an agent key it blocks risky calls and leaves the rest to Claude Code.`);
|
|
102
|
+
}
|
|
103
|
+
return (0, hook_ask_1.hookAskSettings)((0, config_1.readConfigFile)(src), src.env).waitSeconds;
|
|
104
|
+
}
|
|
105
|
+
async function setup(verb, argv, deps) {
|
|
106
|
+
if (verb !== "install" && verb !== "uninstall")
|
|
107
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Use hook claude-code install or hook claude-code uninstall");
|
|
108
|
+
const args = (0, args_1.parseCommandArgs)(argv, OPTIONS);
|
|
109
|
+
if (args.positionals.length)
|
|
110
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Unexpected argument: ${args.positionals[0]}`);
|
|
111
|
+
const configPath = typeof args.values.config === "string" ? absoluteConfigPath(args.values.config, deps.home, deps.cwd()) : undefined;
|
|
112
|
+
const path = typeof args.values.settings === "string" ? args.values.settings : (0, node_path_1.join)(deps.home, ".claude", "settings.json");
|
|
113
|
+
const raw = deps.readFile(path);
|
|
114
|
+
const before = parseSettings(raw, path);
|
|
115
|
+
const wait = verb === "install" ? waitSeconds(deps, configPath) : 0;
|
|
116
|
+
const command = hookCommand(wait, configPath);
|
|
117
|
+
const after = verb === "install" ? installHook(before, command, wait + TIMEOUT_MARGIN_SECONDS) : uninstallHook(before);
|
|
118
|
+
if (JSON.stringify(before) === JSON.stringify(after)) {
|
|
119
|
+
deps.stdout(JSON.stringify({ settings: path, changed: false, written: false }));
|
|
120
|
+
return exit_codes_1.EXIT.ok;
|
|
121
|
+
}
|
|
122
|
+
const afterText = `${JSON.stringify(after, null, indentOf(raw))}\n`;
|
|
123
|
+
deps.stderr(`Changes to ${path}:`);
|
|
124
|
+
for (const line of (0, diff_1.lineDiff)(raw ?? "", afterText))
|
|
125
|
+
deps.stderr(line);
|
|
126
|
+
const write = args.values.yes === true || (await deps.confirm(`Write this to ${path}? [y/N] `));
|
|
127
|
+
if (!write) {
|
|
128
|
+
deps.stdout(JSON.stringify({ settings: path, changed: true, written: false, message: "Nothing written. Run again and confirm, or pass --yes." }));
|
|
129
|
+
return exit_codes_1.EXIT.ok;
|
|
130
|
+
}
|
|
131
|
+
deps.writeFile(path, afterText);
|
|
132
|
+
deps.stdout(JSON.stringify({ settings: path, changed: true, written: true, ...(verb === "install" ? { command } : {}) }));
|
|
133
|
+
return exit_codes_1.EXIT.ok;
|
|
134
|
+
}
|
|
135
|
+
async function runClaudeCodeSetup(argv, deps) {
|
|
136
|
+
try {
|
|
137
|
+
return await setup(argv[0], argv.slice(1), deps);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const exitCode = err instanceof exit_codes_1.CliError ? err.exitCode : exit_codes_1.EXIT.failure;
|
|
141
|
+
deps.stderr(JSON.stringify({ error: err.message, exitCode, status: null }));
|
|
142
|
+
return exitCode;
|
|
143
|
+
}
|
|
144
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { FetchLike, TaskView } from "./client";
|
|
2
|
+
export interface CliDeps {
|
|
3
|
+
env: NodeJS.ProcessEnv;
|
|
4
|
+
home: string;
|
|
5
|
+
readFile(path: string): string | null;
|
|
6
|
+
readBytes(path: string): Uint8Array | null;
|
|
7
|
+
writeFile(path: string, content: string): void;
|
|
8
|
+
removeFile(path: string): void;
|
|
9
|
+
stdout(line: string): void;
|
|
10
|
+
stderr(line: string): void;
|
|
11
|
+
sleep(ms: number): Promise<void>;
|
|
12
|
+
now(): number;
|
|
13
|
+
readStdin(): Promise<string>;
|
|
14
|
+
stdinLines(): AsyncIterable<string>;
|
|
15
|
+
confirm(question: string): Promise<boolean>;
|
|
16
|
+
cwd(): string;
|
|
17
|
+
fetchImpl?: FetchLike;
|
|
18
|
+
name?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function parseWhen(raw: string, flag: string, warn: (line: string) => void): string;
|
|
21
|
+
export declare function parseAction(raw: string): {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
style?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function parseItem(raw: string): {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
selected: boolean;
|
|
30
|
+
};
|
|
31
|
+
export declare function parseRef(raw: string | undefined, flag: string): {
|
|
32
|
+
type: string;
|
|
33
|
+
uuid: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function redactedTail(log: string, tailChars: number): string;
|
|
36
|
+
export declare function itemState(task: TaskView, itemId: string): string;
|
|
37
|
+
export declare function commandName(startedAs: string | undefined): string;
|
|
38
|
+
export declare function runCli(argv: string[], deps: CliDeps): Promise<number>;
|