@anyslate/cli 0.1.0 → 0.2.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 +229 -19
- package/package.json +6 -6
- package/src/commands/checkpoint.mjs +53 -13
- package/src/commands/doctor.mjs +523 -0
- package/src/commands/hook.mjs +70 -19
- package/src/commands/login.mjs +106 -14
- package/src/commands/upload-artifact.mjs +127 -21
- package/src/config.mjs +110 -12
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +39 -5
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- package/src/runlog.mjs +196 -0
- package/src/stdin.mjs +85 -15
- package/src/verify.mjs +262 -0
- package/src/version.mjs +21 -0
- package/templates/git/post-commit +71 -0
package/src/stdin.mjs
CHANGED
|
@@ -1,32 +1,102 @@
|
|
|
1
|
-
// Read stdin to a single string with a soft cap.
|
|
1
|
+
// Read stdin to a single string with a soft cap and an idle timeout.
|
|
2
2
|
//
|
|
3
3
|
// Hook events are tiny (Claude Code: <10 KB) but Bash transcript captures can
|
|
4
|
-
// be larger. Cap at 1 MB
|
|
4
|
+
// be larger. Cap at 1 MB by default; `upload-artifact` raises it to 5 MB.
|
|
5
|
+
//
|
|
6
|
+
// The timeout is an **idle** timeout, reset on every chunk (defect #29). A
|
|
7
|
+
// hook wired to a non-closing pipe (`sleep 300 | anyslate hook post-tool-use`)
|
|
8
|
+
// previously hung forever: the 15 s AbortController is armed inside `callTool`,
|
|
9
|
+
// which is never reached while stdin is still open. An idle timeout terminates
|
|
10
|
+
// that case while still letting a slow 5 MB upload stream through.
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_STDIN_BYTES = 1_000_000;
|
|
13
|
+
export const DEFAULT_STDIN_TIMEOUT_MS = 10_000;
|
|
5
14
|
|
|
6
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the idle timeout, honouring `ANYSLATE_STDIN_TIMEOUT_MS` (a tuning
|
|
17
|
+
* knob for slow pipes, and what the test-suite uses to avoid 10s waits).
|
|
18
|
+
*
|
|
19
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
20
|
+
* @param {number} [fallback]
|
|
21
|
+
* @returns {number}
|
|
22
|
+
*/
|
|
23
|
+
export function stdinTimeoutFromEnv(env = process.env, fallback = DEFAULT_STDIN_TIMEOUT_MS) {
|
|
24
|
+
const raw = env.ANYSLATE_STDIN_TIMEOUT_MS;
|
|
25
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
|
|
26
|
+
const n = Number(raw);
|
|
27
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
28
|
+
}
|
|
7
29
|
|
|
8
30
|
/**
|
|
9
31
|
* @param {NodeJS.ReadableStream} [stream]
|
|
32
|
+
* @param {{ maxBytes?: number, timeoutMs?: number }} [opts]
|
|
10
33
|
* @returns {Promise<string>}
|
|
11
34
|
*/
|
|
12
|
-
export async function readStdin(stream = process.stdin) {
|
|
35
|
+
export async function readStdin(stream = process.stdin, opts = {}) {
|
|
13
36
|
if (stream.isTTY) return '';
|
|
37
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_STDIN_BYTES;
|
|
38
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_STDIN_TIMEOUT_MS;
|
|
39
|
+
|
|
14
40
|
return new Promise((resolve, reject) => {
|
|
15
41
|
const chunks = [];
|
|
16
42
|
let bytes = 0;
|
|
17
|
-
|
|
18
|
-
|
|
43
|
+
let settled = false;
|
|
44
|
+
let timer = null;
|
|
45
|
+
|
|
46
|
+
const detach = () => {
|
|
47
|
+
if (timer) clearTimeout(timer);
|
|
48
|
+
timer = null;
|
|
49
|
+
stream.removeListener('data', onData);
|
|
50
|
+
stream.removeListener('end', onEnd);
|
|
51
|
+
stream.removeListener('error', onError);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const finish = (value) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
detach();
|
|
58
|
+
resolve(value);
|
|
59
|
+
};
|
|
60
|
+
const fail = (err) => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
detach();
|
|
64
|
+
reject(err);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const arm = () => {
|
|
68
|
+
if (timer) clearTimeout(timer);
|
|
69
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
|
|
70
|
+
timer = setTimeout(() => {
|
|
71
|
+
if (typeof stream.pause === 'function') stream.pause();
|
|
72
|
+
fail(
|
|
73
|
+
new Error(`stdin read timed out after ${Math.round(timeoutMs / 1000)}s — no EOF received`),
|
|
74
|
+
);
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function onData(chunk) {
|
|
19
79
|
bytes += Buffer.byteLength(chunk);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
if (bytes > maxBytes) {
|
|
82
|
+
// soft cap - keep what we have, stop reading
|
|
83
|
+
if (typeof stream.pause === 'function') stream.pause();
|
|
84
|
+
finish(chunks.join(''));
|
|
25
85
|
return;
|
|
26
86
|
}
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
87
|
+
arm();
|
|
88
|
+
}
|
|
89
|
+
function onEnd() {
|
|
90
|
+
finish(chunks.join(''));
|
|
91
|
+
}
|
|
92
|
+
function onError(err) {
|
|
93
|
+
fail(err);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stream.setEncoding('utf8');
|
|
97
|
+
stream.on('data', onData);
|
|
98
|
+
stream.on('end', onEnd);
|
|
99
|
+
stream.on('error', onError);
|
|
100
|
+
arm();
|
|
31
101
|
});
|
|
32
102
|
}
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Shared connection verification — used by both `anyslate login` (W3) and
|
|
2
|
+
// `anyslate doctor` (W4) so the two can never disagree about what "working"
|
|
3
|
+
// means.
|
|
4
|
+
//
|
|
5
|
+
// ORDERING IS MANDATORY. A wrong URL 404s at `app.notFound()` (index.ts:113-115)
|
|
6
|
+
// *before* any auth middleware runs, so a `/mcp`-suffixed apiUrl masks every
|
|
7
|
+
// token verdict. URL shape must therefore be resolved before any token verdict
|
|
8
|
+
// is trusted. That is exactly the state found live on this machine: a
|
|
9
|
+
// double-`/mcp` URL AND a revoked token, and the 404 hid the revocation.
|
|
10
|
+
//
|
|
11
|
+
// Side-effect disclosure (do NOT claim this is quiet): `/mcp/auth/verify` runs
|
|
12
|
+
// mcpAuth (UPDATE mcp_tokens SET last_used_at), rateLimit (read bucket,
|
|
13
|
+
// 100/min) and audit (one mcp_audit_logs row, action `auth.read`, plus a
|
|
14
|
+
// connection-sync bump and an `mcp_call` stream event). It is chosen because it
|
|
15
|
+
// is semantically a verification endpoint that returns `scopes` in one round
|
|
16
|
+
// trip and mints no KV session — not because it is free.
|
|
17
|
+
|
|
18
|
+
import { USER_AGENT } from './version.mjs';
|
|
19
|
+
import { classifyNetworkError } from './mcp-client.mjs';
|
|
20
|
+
import { normalizeApiRoot } from './config.mjs';
|
|
21
|
+
|
|
22
|
+
export const TOKEN_MCP_RE = /^as_mcp_[0-9a-f]{48}$/;
|
|
23
|
+
export const TOKEN_OAUTH_RE = /^as_oauth_[0-9a-f]{64}$/;
|
|
24
|
+
|
|
25
|
+
export const REQUIRED_SCOPE = 'memory:write';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Token format contract, verbatim from the bridge (mcp-package/src/index.ts:32-38).
|
|
29
|
+
*
|
|
30
|
+
* The two lengths genuinely differ — 24 random bytes for `as_mcp_`
|
|
31
|
+
* (routes/mcp-tokens.ts:18-25) vs 32 for `as_oauth_`
|
|
32
|
+
* (mcp-service/src/routes/oauth.ts:914-919). A single length rule
|
|
33
|
+
* false-positives on one of them.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} token
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isValidTokenFormat(token) {
|
|
39
|
+
if (typeof token !== 'string') return false;
|
|
40
|
+
return TOKEN_MCP_RE.test(token) || TOKEN_OAUTH_RE.test(token);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A short, safe preview of a rejected token for the error message. */
|
|
44
|
+
export function tokenPreview(token) {
|
|
45
|
+
const s = String(token ?? '');
|
|
46
|
+
return s.length <= 8 ? s : `${s.slice(0, 8)}…`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does this token carry the scope `activity_submit` requires?
|
|
51
|
+
* `hasAnyScope` on the server also honours a literal `'*'`.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} scopes
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function hasWriteScope(scopes) {
|
|
57
|
+
if (!Array.isArray(scopes)) return false;
|
|
58
|
+
return scopes.includes(REQUIRED_SCOPE) || scopes.includes('*');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate the shape of `--api-url` before anything is trusted about a token.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} value
|
|
65
|
+
* @returns {{ok: true, root: string, host: string, normalized: boolean, original: string}
|
|
66
|
+
* | {ok: false, code: string, message: string}}
|
|
67
|
+
*/
|
|
68
|
+
export function checkUrlShape(value) {
|
|
69
|
+
const original = String(value ?? '').trim();
|
|
70
|
+
if (!original) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: 'url_empty',
|
|
74
|
+
message: 'anyslate: --api-url is empty. Pass the service root, e.g. --api-url https://mcp.anyslate.io',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = new URL(original);
|
|
81
|
+
} catch {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: 'url_unparseable',
|
|
85
|
+
message: `anyslate: "${original}" is not a valid URL. Pass the service root, e.g. --api-url https://mcp.anyslate.io`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isLocal =
|
|
90
|
+
parsed.hostname === 'localhost' ||
|
|
91
|
+
parsed.hostname === '127.0.0.1' ||
|
|
92
|
+
parsed.hostname === '::1' ||
|
|
93
|
+
parsed.hostname === '0.0.0.0';
|
|
94
|
+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocal)) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code: 'url_scheme',
|
|
98
|
+
message: `anyslate: --api-url must use https (http is allowed only for localhost). Got "${original}".`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const root = normalizeApiRoot(original);
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
root,
|
|
106
|
+
host: parsed.host,
|
|
107
|
+
original,
|
|
108
|
+
normalized: root !== original.replace(/\/+$/, ''),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One live request that proves reachability, URL shape, token existence,
|
|
114
|
+
* revocation, expiry — and returns `scopes`.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} opts
|
|
117
|
+
* @param {string} opts.root normalized service root
|
|
118
|
+
* @param {string} opts.token
|
|
119
|
+
* @param {typeof fetch} [opts.fetchImpl]
|
|
120
|
+
* @param {number} [opts.timeoutMs]
|
|
121
|
+
* @returns {Promise<{ok: boolean, code: string, message: string, status?: number,
|
|
122
|
+
* userId?: string|null, scopes?: string[], defaultHandleId?: string|null, raw?: unknown}>}
|
|
123
|
+
*/
|
|
124
|
+
export async function probeVerify({ root, token, fetchImpl = fetch, timeoutMs = 10_000 }) {
|
|
125
|
+
const url = `${root}/mcp/auth/verify`;
|
|
126
|
+
let host = root;
|
|
127
|
+
try {
|
|
128
|
+
host = new URL(root).host;
|
|
129
|
+
} catch {
|
|
130
|
+
/* keep root */
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ac = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
135
|
+
let res;
|
|
136
|
+
try {
|
|
137
|
+
res = await fetchImpl(url, {
|
|
138
|
+
method: 'GET',
|
|
139
|
+
headers: {
|
|
140
|
+
authorization: `Bearer ${token}`,
|
|
141
|
+
accept: 'application/json',
|
|
142
|
+
'user-agent': USER_AGENT,
|
|
143
|
+
},
|
|
144
|
+
signal: ac.signal,
|
|
145
|
+
});
|
|
146
|
+
} catch (e) {
|
|
147
|
+
const detail = classifyNetworkError(e, host, timeoutMs);
|
|
148
|
+
const short = /ENOTFOUND|EAI_AGAIN/.test(detail)
|
|
149
|
+
? 'ENOTFOUND'
|
|
150
|
+
: /ECONNREFUSED/.test(detail)
|
|
151
|
+
? 'ECONNREFUSED'
|
|
152
|
+
: /TLS/.test(detail)
|
|
153
|
+
? 'TLS'
|
|
154
|
+
: /timed out/.test(detail)
|
|
155
|
+
? 'timeout'
|
|
156
|
+
: detail;
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
code: 'unreachable',
|
|
160
|
+
message: `anyslate: cannot reach ${host} — ${short}. Check --api-url and your network.`,
|
|
161
|
+
raw: detail,
|
|
162
|
+
};
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let body = null;
|
|
168
|
+
try {
|
|
169
|
+
body = await res.json();
|
|
170
|
+
} catch {
|
|
171
|
+
body = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (res.status === 404) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
code: 'not_found',
|
|
178
|
+
status: 404,
|
|
179
|
+
message: `anyslate: ${root}/mcp/auth/verify returned 404 — this host isn't an AnySlate MCP service, or --api-url includes a path prefix.`,
|
|
180
|
+
raw: body,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (res.status === 401 || res.status === 403) {
|
|
185
|
+
const err = typeof body?.error === 'string' ? body.error : '';
|
|
186
|
+
const desc = typeof body?.error_description === 'string' ? body.error_description : '';
|
|
187
|
+
|
|
188
|
+
if (err === 'authorization_required') {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
code: 'authorization_required',
|
|
192
|
+
status: res.status,
|
|
193
|
+
message: 'anyslate: server rejected the Authorization header. This is a CLI bug — please report it.',
|
|
194
|
+
raw: body,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (/revok/i.test(desc)) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
code: 'revoked',
|
|
201
|
+
status: res.status,
|
|
202
|
+
message: 'anyslate: that token was revoked. Mint a new one at Avatar → API Tokens, then re-run anyslate login.',
|
|
203
|
+
raw: body,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (/expire/i.test(desc)) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
code: 'expired',
|
|
210
|
+
status: res.status,
|
|
211
|
+
message: 'anyslate: that token has expired. Mint a new one at Avatar → API Tokens.',
|
|
212
|
+
raw: body,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
code: 'invalid_token',
|
|
218
|
+
status: res.status,
|
|
219
|
+
message: `anyslate: token not recognised by ${host}. Check for a typo, or that you minted it in the same environment (dev vs prod).`,
|
|
220
|
+
raw: body,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
code: 'http_error',
|
|
228
|
+
status: res.status,
|
|
229
|
+
message: `anyslate: ${host} returned HTTP ${res.status} from /mcp/auth/verify${
|
|
230
|
+
body?.error ? ` — ${body.error}` : ''
|
|
231
|
+
}.`,
|
|
232
|
+
raw: body,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const scopes = Array.isArray(body?.scopes) ? body.scopes : [];
|
|
237
|
+
const userId = body?.user_id ?? body?.userId ?? null;
|
|
238
|
+
const defaultHandleId = body?.default_handle_id ?? body?.defaultHandleId ?? null;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
code: 'verified',
|
|
243
|
+
status: res.status,
|
|
244
|
+
message: `anyslate: verified against ${host} — user ${userId ?? '(unknown)'}, scopes [${scopes.join(',')}]`,
|
|
245
|
+
userId,
|
|
246
|
+
scopes,
|
|
247
|
+
defaultHandleId,
|
|
248
|
+
raw: body,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Exact scope warning (warn, never block — the token may still be usable for
|
|
254
|
+
* reads, and blocking here would be a new class of false negative).
|
|
255
|
+
*
|
|
256
|
+
* @param {string[]} scopes
|
|
257
|
+
* @returns {string|null}
|
|
258
|
+
*/
|
|
259
|
+
export function scopeWarning(scopes) {
|
|
260
|
+
if (hasWriteScope(scopes)) return null;
|
|
261
|
+
return `anyslate: warning — token scopes are [${(scopes || []).join(',')}]; \`anyslate hook\` needs ${REQUIRED_SCOPE}. Mint a token with the Memory profile.`;
|
|
262
|
+
}
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Single source of truth for the CLI version.
|
|
2
|
+
//
|
|
3
|
+
// Read from package.json at runtime rather than duplicated as a literal in
|
|
4
|
+
// index.mjs and mcp-client.mjs (the User-Agent). npm always ships
|
|
5
|
+
// package.json inside the tarball, so this resolves in an installed package
|
|
6
|
+
// exactly as it does in the repo.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
function read() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return typeof parsed?.version === 'string' ? parsed.version : '0.0.0';
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const VERSION = read();
|
|
21
|
+
export const USER_AGENT = `anyslate-cli/${VERSION}`;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# AnySlate — git post-commit hook (Phase 15.3)
|
|
3
|
+
#
|
|
4
|
+
# Drop this file at .git/hooks/post-commit (chmod +x). On every commit it
|
|
5
|
+
# pipes a small JSON payload to `anyslate hook post-tool-use`, which submits
|
|
6
|
+
# a `task_completed` activity to your AnySlate Activity feed via the
|
|
7
|
+
# activity_submit MCP tool. Low-risk same-session items auto-promote into
|
|
8
|
+
# canonical memory after a 30-second quiet period; ambiguous items wait for
|
|
9
|
+
# you to approve them in the desktop Activity panel.
|
|
10
|
+
#
|
|
11
|
+
# Requirements:
|
|
12
|
+
# - anyslate CLI installed (`npx @anyslate/cli` or `npm i -g @anyslate/cli`)
|
|
13
|
+
# - ANYSLATE_MCP_TOKEN configured (or `anyslate login --token <BEARER>`)
|
|
14
|
+
#
|
|
15
|
+
# Optional env:
|
|
16
|
+
# ANYSLATE_SESSION AI session id to route the checkpoint into. If unset,
|
|
17
|
+
# the activity classifier will mark this high-risk so
|
|
18
|
+
# you can approve + assign in the desktop UI.
|
|
19
|
+
# ANYSLATE_HOST host_hint advisory (default: git_post_commit)
|
|
20
|
+
# ANYSLATE_QUIET if set, swallow CLI stderr (the hook itself never
|
|
21
|
+
# blocks the commit either way)
|
|
22
|
+
|
|
23
|
+
set -u
|
|
24
|
+
|
|
25
|
+
if ! command -v anyslate >/dev/null 2>&1; then
|
|
26
|
+
if ! command -v npx >/dev/null 2>&1; then
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
ANYSLATE_BIN=("npx" "--yes" "@anyslate/cli")
|
|
30
|
+
else
|
|
31
|
+
ANYSLATE_BIN=("anyslate")
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo unknown)"
|
|
35
|
+
REPO_NAME="$(basename "${REPO_ROOT}")"
|
|
36
|
+
SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
|
37
|
+
SHORT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
38
|
+
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
|
39
|
+
SUBJECT="$(git log -1 --pretty=%s 2>/dev/null || echo)"
|
|
40
|
+
AUTHOR="$(git log -1 --pretty='%an' 2>/dev/null || echo)"
|
|
41
|
+
STATS="$(git show --stat --pretty='' HEAD 2>/dev/null | tail -1 | tr -d '\n')"
|
|
42
|
+
|
|
43
|
+
read -r -d '' EVENT <<JSON || true
|
|
44
|
+
{
|
|
45
|
+
"session_id": "${ANYSLATE_SESSION:-}",
|
|
46
|
+
"tool_name": "GitCommit",
|
|
47
|
+
"tool_response": {
|
|
48
|
+
"repo": "${REPO_NAME}",
|
|
49
|
+
"branch": "${BRANCH}",
|
|
50
|
+
"sha": "${SHA}",
|
|
51
|
+
"short_sha": "${SHORT_SHA}",
|
|
52
|
+
"subject": $(printf '%s' "${SUBJECT}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "${SUBJECT//\"/\\\"}"),
|
|
53
|
+
"author": $(printf '%s' "${AUTHOR}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "${AUTHOR//\"/\\\"}"),
|
|
54
|
+
"stats": $(printf '%s' "${STATS}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "${STATS//\"/\\\"}")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
JSON
|
|
58
|
+
|
|
59
|
+
cli_args=(hook post-tool-use --host "${ANYSLATE_HOST:-git_post_commit}")
|
|
60
|
+
cli_args+=(--note "git commit on ${REPO_NAME}@${BRANCH}: ${SUBJECT}")
|
|
61
|
+
if [ -n "${ANYSLATE_SESSION:-}" ]; then
|
|
62
|
+
cli_args+=(--session "${ANYSLATE_SESSION}")
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
if [ -n "${ANYSLATE_QUIET:-}" ]; then
|
|
66
|
+
printf '%s\n' "${EVENT}" | "${ANYSLATE_BIN[@]}" "${cli_args[@]}" >/dev/null 2>&1 || true
|
|
67
|
+
else
|
|
68
|
+
printf '%s\n' "${EVENT}" | "${ANYSLATE_BIN[@]}" "${cli_args[@]}" >/dev/null || true
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
exit 0
|