@anyslate/cli 0.1.0 → 0.3.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/src/runlog.mjs ADDED
@@ -0,0 +1,196 @@
1
+ // W7 — persisted observability for a fail-open CLI.
2
+ //
3
+ // Exit 0 is correct policy: a network blip must never break a Claude Code
4
+ // session. But the error strategy was "write to stderr", and Claude Code files
5
+ // `exit 0 + stderr` as `hook_success` — 0 of 4,896 production hook_success
6
+ // records carry stderr. The entire error channel had no receiver.
7
+ //
8
+ // So: persist every run. `~/.anyslate/cli-last-run.json` (mode 0600) plus a
9
+ // capped NDJSON ring log. `anyslate doctor` reads them. Nothing here changes an
10
+ // exit code.
11
+ //
12
+ // REDACTION: the record must never contain token material. Every value is run
13
+ // through `redact()` before it is written, and there is a test asserting the
14
+ // file never contains `as_mcp_` / `as_oauth_`.
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, renameSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { anyslateDir } from './config.mjs';
19
+
20
+ export const LAST_RUN_FILE = 'cli-last-run.json';
21
+ export const RING_LOG_FILE = 'cli-runs.ndjson';
22
+
23
+ /** Escalate only on persistence, not incidence. One failure is a blip. */
24
+ export const ESCALATE_AFTER_CONSECUTIVE_FAILURES = 3;
25
+
26
+ const RING_MAX_ENTRIES = 200;
27
+ const RING_MAX_BYTES = 256 * 1024;
28
+
29
+ const TOKEN_RE = /\b(as_mcp_|as_oauth_)[A-Za-z0-9_-]*/g;
30
+ const BEARER_RE = /\bBearer\s+\S+/gi;
31
+
32
+ /**
33
+ * Strip anything that looks like credential material from arbitrary data.
34
+ * @param {unknown} value
35
+ * @returns {unknown}
36
+ */
37
+ export function redact(value) {
38
+ if (value == null) return value;
39
+ if (typeof value === 'string') {
40
+ return value.replace(TOKEN_RE, '[redacted-token]').replace(BEARER_RE, 'Bearer [redacted]');
41
+ }
42
+ if (Array.isArray(value)) return value.map(redact);
43
+ if (typeof value === 'object') {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(value)) {
46
+ if (/token|secret|authorization|password|bearer/i.test(k)) {
47
+ out[k] = v == null ? v : '[redacted]';
48
+ continue;
49
+ }
50
+ out[k] = redact(v);
51
+ }
52
+ return out;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ export function lastRunPath(env = process.env) {
58
+ return join(anyslateDir(env), LAST_RUN_FILE);
59
+ }
60
+ export function ringLogPath(env = process.env) {
61
+ return join(anyslateDir(env), RING_LOG_FILE);
62
+ }
63
+
64
+ /**
65
+ * @param {NodeJS.ProcessEnv} [env]
66
+ * @returns {object|null}
67
+ */
68
+ export function readLastRun(env = process.env) {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(lastRunPath(env), 'utf8'));
71
+ return parsed && typeof parsed === 'object' ? parsed : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Persist the outcome of one CLI invocation.
79
+ *
80
+ * Never throws — an unwritable home directory must not turn a successful
81
+ * capture into a failure.
82
+ *
83
+ * @param {object} entry
84
+ * @param {string} entry.command
85
+ * @param {boolean} entry.ok
86
+ * @param {string} [entry.apiUrl]
87
+ * @param {number} [entry.status]
88
+ * @param {boolean} [entry.isError]
89
+ * @param {boolean} [entry.networkError]
90
+ * @param {unknown} [entry.error]
91
+ * @param {number} [entry.exitCode]
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {{consecutiveFailures: number, lastSuccessAt: string|null, failingSince: string|null}}
94
+ */
95
+ export function recordRun(entry, env = process.env) {
96
+ const previous = readLastRun(env) || {};
97
+ const ts = new Date().toISOString();
98
+
99
+ const consecutiveFailures = entry.ok
100
+ ? 0
101
+ : Number.isFinite(previous.consecutive_failures)
102
+ ? Number(previous.consecutive_failures) + 1
103
+ : 1;
104
+ const lastSuccessAt = entry.ok ? ts : (previous.last_success_at ?? null);
105
+ const failingSince = entry.ok ? null : (previous.failing_since ?? ts);
106
+
107
+ const record = redact({
108
+ ts,
109
+ command: entry.command,
110
+ ok: !!entry.ok,
111
+ exit_code: entry.exitCode ?? (entry.ok ? 0 : 1),
112
+ api_url: entry.apiUrl ?? null,
113
+ endpoint: entry.apiUrl ? `${String(entry.apiUrl).replace(/\/+$/, '')}/mcp` : null,
114
+ http_status: entry.status ?? null,
115
+ is_error: entry.isError ?? false,
116
+ network_error: entry.networkError ?? false,
117
+ error: entry.error == null ? null : typeof entry.error === 'string' ? entry.error : JSON.stringify(entry.error),
118
+ consecutive_failures: consecutiveFailures,
119
+ last_success_at: lastSuccessAt,
120
+ failing_since: failingSince,
121
+ cli_version: entry.version ?? null,
122
+ });
123
+
124
+ try {
125
+ const dir = anyslateDir(env);
126
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+ writeFileSync(join(dir, LAST_RUN_FILE), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
128
+ appendRing(dir, record);
129
+ } catch {
130
+ // Observability must never be load-bearing.
131
+ }
132
+
133
+ return { consecutiveFailures, lastSuccessAt, failingSince };
134
+ }
135
+
136
+ function appendRing(dir, record) {
137
+ const path = join(dir, RING_LOG_FILE);
138
+ try {
139
+ let size = 0;
140
+ try {
141
+ size = statSync(path).size;
142
+ } catch {
143
+ size = 0;
144
+ }
145
+ if (size > RING_MAX_BYTES) {
146
+ // Cheap rotation: keep the newest half.
147
+ try {
148
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
149
+ const keep = lines.slice(-Math.floor(RING_MAX_ENTRIES / 2));
150
+ writeFileSync(`${path}.tmp`, `${keep.join('\n')}\n`, { mode: 0o600 });
151
+ renameSync(`${path}.tmp`, path);
152
+ } catch {
153
+ writeFileSync(path, '', { mode: 0o600 });
154
+ }
155
+ }
156
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
157
+ } catch {
158
+ // ignore
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Should a SessionStart hook escalate to the model/user?
164
+ *
165
+ * @param {{consecutiveFailures: number, failingSince: string|null}} state
166
+ * @returns {boolean}
167
+ */
168
+ export function shouldEscalate(state) {
169
+ if (!state) return false;
170
+ if (state.consecutiveFailures >= ESCALATE_AFTER_CONSECUTIVE_FAILURES) return true;
171
+ if (state.failingSince) {
172
+ const since = Date.parse(state.failingSince);
173
+ if (Number.isFinite(since) && Date.now() - since > 24 * 60 * 60 * 1000) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /**
179
+ * The structured stdout block Claude Code actually renders.
180
+ * `hook_system_message` and `hook_additional_context` are both verified working
181
+ * channels; plain stderr is not.
182
+ *
183
+ * @param {{consecutiveFailures: number, failingSince: string|null, error?: string|null}} state
184
+ * @returns {string}
185
+ */
186
+ export function escalationPayload(state) {
187
+ const since = state.failingSince ? ` since ${String(state.failingSince).slice(0, 10)}` : '';
188
+ const because = state.error ? ` (${state.error})` : '';
189
+ return `${JSON.stringify({
190
+ hookSpecificOutput: {
191
+ hookEventName: 'SessionStart',
192
+ additionalContext: `AnySlate capture has been failing${since}${because}. Run \`anyslate doctor\`.`,
193
+ },
194
+ systemMessage: 'AnySlate capture is failing — run `anyslate doctor`.',
195
+ })}\n`;
196
+ }
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 to stay well below the activity_submit insert limits.
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
- const MAX_STDIN_BYTES = 1_000_000;
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
- stream.setEncoding('utf8');
18
- stream.on('data', (chunk) => {
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
- if (bytes > MAX_STDIN_BYTES) {
21
- // soft cap keep what we have, stop reading
22
- stream.removeAllListeners('data');
23
- chunks.push(chunk);
24
- resolve(chunks.join(''));
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
- chunks.push(chunk);
28
- });
29
- stream.on('end', () => resolve(chunks.join('')));
30
- stream.on('error', reject);
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
+ }
@@ -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