@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/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