@anyslate/cli 0.1.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 ADDED
@@ -0,0 +1,76 @@
1
+ # @anyslate/cli
2
+
3
+ Thin lifecycle-hook + checkpoint client for the AnySlate AI Memory Layer.
4
+
5
+ All hook subcommands submit to your **Activity feed** via the `activity_submit` MCP tool (Phase 14). Low-risk same-session items auto-promote into canonical memory after a 30-second quiet period; ambiguous items wait for you to approve them in the desktop **AI Memory → Activity** panel.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i -g @anyslate/cli
11
+ # or
12
+ npx @anyslate/cli --help
13
+ ```
14
+
15
+ Requires Node ≥ 20.
16
+
17
+ ## Authenticate
18
+
19
+ Mint an MCP token in the desktop app at **Settings → AI Memory → Connect**, then:
20
+
21
+ ```bash
22
+ anyslate login --token as_mcp_your_token_here
23
+ ```
24
+
25
+ Or set environment variables:
26
+
27
+ ```bash
28
+ export ANYSLATE_MCP_TOKEN=as_mcp_your_token_here
29
+ export ANYSLATE_API_URL=https://mcp.anyslate.io # optional
30
+ export ANYSLATE_HANDLE=h_xxx # optional, scope to one handle
31
+ ```
32
+
33
+ Env vars override `~/.anyslate/cli.json`.
34
+
35
+ ## Subcommands
36
+
37
+ ```
38
+ anyslate hook <session-start|post-tool-use|stop> [--strict]
39
+ anyslate checkpoint --note "..." [--kind milestone] [--session <id>]
40
+ anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
41
+ anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]
42
+ anyslate version | help
43
+ ```
44
+
45
+ ### `anyslate hook ...`
46
+
47
+ Reads JSON event payload from stdin (Claude Code lifecycle event format). Fails open by default — a network blip logs a warning to stderr and exits 0 so your Claude Code session continues. Pass `--strict` to opt into exit 1 on failure (useful in CI smoke tests).
48
+
49
+ `hook session-start` → `topic_shift`; `hook stop` → `conversation_end`; `hook post-tool-use` maps Edit/Write/MultiEdit → `artifact_produced`, Bash/Shell/Run → `task_completed`, everything else → `topic_shift`.
50
+
51
+ ### `anyslate checkpoint`
52
+
53
+ User-initiated, exits non-zero on failure. Defaults `--kind milestone`, `--source api`. Allowed kinds: `topic_shift | decision_committed | task_completed | task_added | artifact_produced | milestone | conversation_end`.
54
+
55
+ ### `anyslate upload-artifact`
56
+
57
+ Reads file or stdin, calls the `upload_artifact` MCP tool. Returns `cloud://artifact/<id>` on stdout. Allowed kinds: `code_block | file_path | error_message | shell_command | config_snippet | url_reference | fenced_quote`. 5 MB content cap.
58
+
59
+ ### `anyslate login`
60
+
61
+ Writes `~/.anyslate/cli.json` with mode `0600`. Idempotent — preserves the apiUrl/handle if you only update the token.
62
+
63
+ ## Wiring into Claude Code
64
+
65
+ See `docs/AI_MEMORY_LAYER/GUIDE.md` §6.5 for the full `~/.claude/settings.json` block.
66
+
67
+ ## Tests
68
+
69
+ ```bash
70
+ npm test
71
+ # 23 tests · node --test · no external deps
72
+ ```
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/index.mjs';
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => process.exit(typeof code === 'number' ? code : 0),
6
+ (err) => {
7
+ process.stderr.write(`anyslate: ${err?.message ?? err}\n`);
8
+ process.exit(1);
9
+ }
10
+ );
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@anyslate/cli",
3
+ "version": "0.1.0",
4
+ "description": "AnySlate CLI — lifecycle hooks + checkpoint / upload-artifact for AI memory capture. All hook subcommands submit to the Activity feed via the activity_submit MCP tool (Phase 14).",
5
+ "type": "module",
6
+ "bin": {
7
+ "anyslate": "./bin/anyslate.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src/*.mjs",
12
+ "src/commands",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test 'src/__tests__/*.test.mjs'",
17
+ "publish:npm": "dotenv -e .env -- npm publish --access=public"
18
+ },
19
+ "devDependencies": {
20
+ "dotenv-cli": "^11.0.0"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "keywords": [
26
+ "anyslate",
27
+ "mcp",
28
+ "claude-code",
29
+ "ai",
30
+ "memory",
31
+ "lifecycle-hooks",
32
+ "cli"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/AnySlate/anyslate.git",
38
+ "directory": "cli"
39
+ }
40
+ }
@@ -0,0 +1,87 @@
1
+ // `anyslate checkpoint` — submit an explicit checkpoint to the Activity feed.
2
+ //
3
+ // Unlike `hook`, this is user-initiated, so we surface failures directly
4
+ // (exit 1 on error). Use this in CI or one-off scripts where you actually
5
+ // want to know if the submit failed.
6
+
7
+ import { loadConfig, requireToken } from '../config.mjs';
8
+ import { callTool } from '../mcp-client.mjs';
9
+
10
+ const ALLOWED_KINDS = new Set([
11
+ 'topic_shift', 'decision_committed', 'task_completed', 'task_added',
12
+ 'artifact_produced', 'milestone', 'conversation_end',
13
+ ]);
14
+
15
+ /**
16
+ * @param {string[]} argv arguments after `checkpoint`
17
+ * @returns {Promise<number>}
18
+ */
19
+ export async function runCheckpoint(argv) {
20
+ const flags = parseFlags(argv);
21
+ if (!flags.note) {
22
+ process.stderr.write('usage: anyslate checkpoint --note <text> [--kind <kind>] [--session <id>] [--host <hint>] [--source <source>]\n');
23
+ process.stderr.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
24
+ return 2;
25
+ }
26
+ const kind = flags.kind || 'milestone';
27
+ if (!ALLOWED_KINDS.has(kind)) {
28
+ process.stderr.write(`anyslate checkpoint: unsupported kind: ${kind}\n`);
29
+ return 2;
30
+ }
31
+
32
+ const cfg = loadConfig();
33
+ const tokenCheck = requireToken(cfg);
34
+ if (!tokenCheck.ok) {
35
+ process.stderr.write(`anyslate checkpoint: ${tokenCheck.error}\n`);
36
+ return 1;
37
+ }
38
+
39
+ const payload = {
40
+ notes: flags.note,
41
+ host_hint: flags.host || 'cli',
42
+ };
43
+ if (flags.session) payload.session_id = flags.session;
44
+ if (flags.confidence !== undefined) payload.confidence = flags.confidence;
45
+
46
+ const args = {
47
+ source: flags.source || 'api',
48
+ kind,
49
+ payload,
50
+ };
51
+ if (cfg.handle) args.handle = cfg.handle;
52
+ if (flags.session) args.session_id_hint = flags.session;
53
+
54
+ try {
55
+ const res = await callTool({
56
+ apiUrl: cfg.apiUrl,
57
+ token: cfg.mcpToken,
58
+ toolName: 'activity_submit',
59
+ args,
60
+ });
61
+ if (!res.ok) {
62
+ const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
63
+ process.stderr.write(`anyslate checkpoint: server ${res.status} — ${msg}\n`);
64
+ return 1;
65
+ }
66
+ process.stdout.write(`${JSON.stringify(res.data)}\n`);
67
+ return 0;
68
+ } catch (e) {
69
+ process.stderr.write(`anyslate checkpoint: request failed (${e?.message ?? e})\n`);
70
+ return 1;
71
+ }
72
+ }
73
+
74
+ /** @param {string[]} argv */
75
+ function parseFlags(argv) {
76
+ const out = {};
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const a = argv[i];
79
+ if (a === '--note' && argv[i + 1]) out.note = argv[++i];
80
+ else if (a === '--kind' && argv[i + 1]) out.kind = argv[++i];
81
+ else if (a === '--session' && argv[i + 1]) out.session = argv[++i];
82
+ else if (a === '--host' && argv[i + 1]) out.host = argv[++i];
83
+ else if (a === '--source' && argv[i + 1]) out.source = argv[++i];
84
+ else if (a === '--confidence' && argv[i + 1]) out.confidence = Number(argv[++i]);
85
+ }
86
+ return out;
87
+ }
@@ -0,0 +1,98 @@
1
+ // `anyslate hook <subcommand>` — submit a lifecycle event to the Activity feed.
2
+ //
3
+ // Subcommands:
4
+ // anyslate hook session-start
5
+ // anyslate hook post-tool-use
6
+ // anyslate hook stop
7
+ //
8
+ // Hooks fail open: any error short-circuits to a stderr warning + exit 0 so a
9
+ // misconfigured CLI never breaks the parent Claude Code session. Pass
10
+ // --strict to opt into exit 1 on failure (useful for setup verification).
11
+
12
+ import { loadConfig, requireToken } from '../config.mjs';
13
+ import { callTool } from '../mcp-client.mjs';
14
+ import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
15
+ import { readStdin } from '../stdin.mjs';
16
+
17
+ const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
18
+
19
+ /**
20
+ * @param {string[]} argv arguments after `hook`
21
+ * @returns {Promise<number>}
22
+ */
23
+ export async function runHook(argv) {
24
+ const sub = argv[0];
25
+ if (!sub || !ALLOWED.has(sub)) {
26
+ process.stderr.write('usage: anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]\n');
27
+ return 2;
28
+ }
29
+
30
+ const flags = parseFlags(argv.slice(1));
31
+ const strict = flags.strict;
32
+ const cfg = loadConfig();
33
+
34
+ const tokenCheck = requireToken(cfg);
35
+ if (!tokenCheck.ok) {
36
+ process.stderr.write(`anyslate hook ${sub}: ${tokenCheck.error}\n`);
37
+ return strict ? 1 : 0;
38
+ }
39
+
40
+ let stdinRaw = '';
41
+ try {
42
+ stdinRaw = await readStdin();
43
+ } catch (e) {
44
+ process.stderr.write(`anyslate hook ${sub}: stdin read failed (${e?.message ?? e})\n`);
45
+ return strict ? 1 : 0;
46
+ }
47
+
48
+ const event = parseHookEvent(stdinRaw);
49
+ const submission = buildHookSubmission({
50
+ hook: /** @type {any} */ (sub),
51
+ event,
52
+ host: flags.host || 'claude-code',
53
+ sessionId: flags.session,
54
+ notes: flags.note,
55
+ });
56
+
57
+ const args = {
58
+ source: submission.source,
59
+ kind: submission.kind,
60
+ payload: submission.payload,
61
+ };
62
+ if (cfg.handle) args.handle = cfg.handle;
63
+ if (submission.sessionIdHint) args.session_id_hint = submission.sessionIdHint;
64
+
65
+ try {
66
+ const res = await callTool({
67
+ apiUrl: cfg.apiUrl,
68
+ token: cfg.mcpToken,
69
+ toolName: 'activity_submit',
70
+ args,
71
+ });
72
+ if (!res.ok) {
73
+ const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
74
+ process.stderr.write(`anyslate hook ${sub}: server ${res.status} — ${msg}\n`);
75
+ return strict ? 1 : 0;
76
+ }
77
+ if (process.env.ANYSLATE_VERBOSE) {
78
+ process.stdout.write(`${JSON.stringify(res.data)}\n`);
79
+ }
80
+ return 0;
81
+ } catch (e) {
82
+ process.stderr.write(`anyslate hook ${sub}: request failed (${e?.message ?? e})\n`);
83
+ return strict ? 1 : 0;
84
+ }
85
+ }
86
+
87
+ /** @param {string[]} argv */
88
+ function parseFlags(argv) {
89
+ const out = { strict: false };
90
+ for (let i = 0; i < argv.length; i += 1) {
91
+ const a = argv[i];
92
+ if (a === '--strict') out.strict = true;
93
+ else if (a === '--session' && argv[i + 1]) { out.session = argv[++i]; }
94
+ else if (a === '--note' && argv[i + 1]) { out.note = argv[++i]; }
95
+ else if (a === '--host' && argv[i + 1]) { out.host = argv[++i]; }
96
+ }
97
+ return out;
98
+ }
@@ -0,0 +1,67 @@
1
+ // `anyslate login` — write `~/.anyslate/cli.json` with the user's MCP token.
2
+ //
3
+ // Mint the token in the desktop app at Settings → AI Memory → Connect → Mint
4
+ // MCP Token (Professional tier only). Then:
5
+ //
6
+ // anyslate login --token <BEARER>
7
+ // anyslate login --token <BEARER> --handle <HANDLE_ID>
8
+ // anyslate login --token <BEARER> --api-url https://anyslate-mcp-service-development.<workers-dev-url>
9
+ //
10
+ // The file is written with mode 0600 — only the current user can read it.
11
+
12
+ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ /**
17
+ * @param {string[]} argv arguments after `login`
18
+ * @returns {Promise<number>}
19
+ */
20
+ export async function runLogin(argv) {
21
+ const flags = parseFlags(argv);
22
+ if (!flags.token) {
23
+ process.stderr.write('usage: anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]\n');
24
+ return 2;
25
+ }
26
+
27
+ const dir = join(homedir(), '.anyslate');
28
+ const path = join(dir, 'cli.json');
29
+ let existing = {};
30
+ try {
31
+ existing = JSON.parse(readFileSync(path, 'utf8')) || {};
32
+ } catch {
33
+ existing = {};
34
+ }
35
+
36
+ const next = {
37
+ ...existing,
38
+ mcp_token: flags.token,
39
+ handle: flags.handle ?? existing.handle ?? null,
40
+ apiUrl: flags.apiUrl ?? existing.apiUrl ?? 'https://mcp.anyslate.io',
41
+ };
42
+
43
+ try {
44
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
45
+ writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
46
+ } catch (e) {
47
+ process.stderr.write(`anyslate login: write failed (${e?.message ?? e})\n`);
48
+ return 1;
49
+ }
50
+
51
+ process.stdout.write(`anyslate: wrote ${path}\n`);
52
+ process.stdout.write(` apiUrl: ${next.apiUrl}\n`);
53
+ process.stdout.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
54
+ return 0;
55
+ }
56
+
57
+ /** @param {string[]} argv */
58
+ function parseFlags(argv) {
59
+ const out = {};
60
+ for (let i = 0; i < argv.length; i += 1) {
61
+ const a = argv[i];
62
+ if (a === '--token' && argv[i + 1]) out.token = argv[++i];
63
+ else if (a === '--handle' && argv[i + 1]) out.handle = argv[++i];
64
+ else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1,110 @@
1
+ // `anyslate upload-artifact` — upload a file (or stdin) as an MCP artifact.
2
+ //
3
+ // Usage:
4
+ // anyslate upload-artifact --session <id> --kind code_block --file ./diff.patch
5
+ // echo "..." | anyslate upload-artifact --session <id> --kind shell_command
6
+ //
7
+ // Returns the cloud://artifact/<id> URI on stdout (one per line) so callers
8
+ // can pipe it into a subsequent `anyslate checkpoint --note ...` if desired.
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { basename } from 'node:path';
12
+ import { loadConfig, requireToken } from '../config.mjs';
13
+ import { callTool } from '../mcp-client.mjs';
14
+ import { readStdin } from '../stdin.mjs';
15
+
16
+ const ALLOWED_KINDS = new Set([
17
+ 'code_block', 'file_path', 'error_message', 'shell_command',
18
+ 'config_snippet', 'url_reference', 'fenced_quote',
19
+ ]);
20
+
21
+ const MAX_CONTENT_BYTES = 5_000_000;
22
+
23
+ /**
24
+ * @param {string[]} argv arguments after `upload-artifact`
25
+ * @returns {Promise<number>}
26
+ */
27
+ export async function runUploadArtifact(argv) {
28
+ const flags = parseFlags(argv);
29
+ if (!flags.session || !flags.kind) {
30
+ process.stderr.write('usage: anyslate upload-artifact --session <id> --kind <kind> [--file <path> | <stdin>] [--language <lang>] [--path-hint <name>]\n');
31
+ process.stderr.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
32
+ return 2;
33
+ }
34
+ if (!ALLOWED_KINDS.has(flags.kind)) {
35
+ process.stderr.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
36
+ return 2;
37
+ }
38
+
39
+ const cfg = loadConfig();
40
+ const tokenCheck = requireToken(cfg);
41
+ if (!tokenCheck.ok) {
42
+ process.stderr.write(`anyslate upload-artifact: ${tokenCheck.error}\n`);
43
+ return 1;
44
+ }
45
+
46
+ let content;
47
+ let pathHint = flags.pathHint;
48
+ if (flags.file) {
49
+ try {
50
+ content = readFileSync(flags.file, 'utf8');
51
+ if (!pathHint) pathHint = basename(flags.file);
52
+ } catch (e) {
53
+ process.stderr.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
54
+ return 1;
55
+ }
56
+ } else {
57
+ content = await readStdin();
58
+ }
59
+
60
+ if (!content || !content.length) {
61
+ process.stderr.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
62
+ return 1;
63
+ }
64
+ if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
65
+ process.stderr.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
66
+ return 1;
67
+ }
68
+
69
+ const args = {
70
+ session_id: flags.session,
71
+ kind: flags.kind,
72
+ content,
73
+ };
74
+ if (cfg.handle) args.handle = cfg.handle;
75
+ if (flags.language) args.language = flags.language;
76
+ if (pathHint) args.path_hint = pathHint;
77
+
78
+ try {
79
+ const res = await callTool({
80
+ apiUrl: cfg.apiUrl,
81
+ token: cfg.mcpToken,
82
+ toolName: 'upload_artifact',
83
+ args,
84
+ });
85
+ if (!res.ok) {
86
+ const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
87
+ process.stderr.write(`anyslate upload-artifact: server ${res.status} — ${msg}\n`);
88
+ return 1;
89
+ }
90
+ process.stdout.write(`${JSON.stringify(res.data)}\n`);
91
+ return 0;
92
+ } catch (e) {
93
+ process.stderr.write(`anyslate upload-artifact: request failed (${e?.message ?? e})\n`);
94
+ return 1;
95
+ }
96
+ }
97
+
98
+ /** @param {string[]} argv */
99
+ function parseFlags(argv) {
100
+ const out = {};
101
+ for (let i = 0; i < argv.length; i += 1) {
102
+ const a = argv[i];
103
+ if (a === '--session' && argv[i + 1]) out.session = argv[++i];
104
+ else if (a === '--kind' && argv[i + 1]) out.kind = argv[++i];
105
+ else if (a === '--file' && argv[i + 1]) out.file = argv[++i];
106
+ else if (a === '--language' && argv[i + 1]) out.language = argv[++i];
107
+ else if ((a === '--path-hint' || a === '--path_hint') && argv[i + 1]) out.pathHint = argv[++i];
108
+ }
109
+ return out;
110
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Config resolution for anyslate-cli.
2
+ //
3
+ // Order of precedence (first non-empty wins):
4
+ // 1. Environment variables (ANYSLATE_API_URL, ANYSLATE_MCP_TOKEN, ANYSLATE_HANDLE)
5
+ // 2. ~/.anyslate/cli.json (preferred CLI config)
6
+ // 3. ~/.anyslate/session.json (legacy, only the `mcp_token` / `handle` keys)
7
+ //
8
+ // Defaults:
9
+ // - apiUrl → https://mcp.anyslate.io
10
+ //
11
+ // All file reads tolerate missing / unreadable files; the CLI fails open so
12
+ // `anyslate hook session-start` never breaks a Claude Code session because of
13
+ // a missing config — it just no-ops with a warning to stderr.
14
+
15
+ import { readFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ export const DEFAULT_API_URL = 'https://mcp.anyslate.io';
20
+
21
+ const CONFIG_PATHS = [
22
+ () => join(homedir(), '.anyslate', 'cli.json'),
23
+ () => join(homedir(), '.anyslate', 'session.json'),
24
+ ];
25
+
26
+ /**
27
+ * @param {NodeJS.ProcessEnv} env
28
+ * @param {() => string[]} [paths] override for tests
29
+ * @returns {{apiUrl: string, mcpToken: string|null, handle: string|null, source: string}}
30
+ */
31
+ export function loadConfig(env = process.env, paths) {
32
+ const candidates = paths ? paths() : CONFIG_PATHS.map((fn) => fn());
33
+
34
+ /** @type {{apiUrl?: string, mcp_token?: string, handle?: string}} */
35
+ let fileConfig = {};
36
+ let source = 'env';
37
+ for (const p of candidates) {
38
+ try {
39
+ const raw = readFileSync(p, 'utf8');
40
+ const parsed = JSON.parse(raw);
41
+ if (parsed && typeof parsed === 'object') {
42
+ fileConfig = parsed;
43
+ source = p;
44
+ break;
45
+ }
46
+ } catch {
47
+ // ignore — fall through to next candidate
48
+ }
49
+ }
50
+
51
+ const apiUrl = (env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
52
+ const mcpToken = env.ANYSLATE_MCP_TOKEN || fileConfig.mcp_token || fileConfig.token || null;
53
+ const handle = env.ANYSLATE_HANDLE || fileConfig.handle || null;
54
+
55
+ return { apiUrl, mcpToken, handle, source };
56
+ }
57
+
58
+ /**
59
+ * @param {object} cfg
60
+ * @returns {{ok: true} | {ok: false, error: string}}
61
+ */
62
+ export function requireToken(cfg) {
63
+ if (!cfg.mcpToken) {
64
+ return {
65
+ ok: false,
66
+ error:
67
+ 'no MCP token configured. Run `anyslate login --token <BEARER>` or set ANYSLATE_MCP_TOKEN. Mint a token at Settings → AI Memory → Connect in the AnySlate desktop app.',
68
+ };
69
+ }
70
+ return { ok: true };
71
+ }
package/src/hooks.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // Hook payload shaping.
2
+ //
3
+ // Claude Code lifecycle hooks pipe a JSON event on stdin. We're tolerant:
4
+ // missing / empty / non-JSON stdin is fine — we still produce a valid
5
+ // activity_submit payload, just with less context. This keeps the CLI from
6
+ // breaking a Claude Code session because of an unexpected hook shape.
7
+ //
8
+ // This module is pure (no I/O) so it can be unit-tested directly.
9
+
10
+ const TRUNC_NOTES = 4_000;
11
+ const TRUNC_EXCERPT = 16_000;
12
+
13
+ /**
14
+ * @param {string|null|undefined} raw
15
+ * @returns {Record<string, unknown>}
16
+ */
17
+ export function parseHookEvent(raw) {
18
+ if (!raw || !raw.trim()) return {};
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @param {unknown} v
29
+ * @param {number} cap
30
+ */
31
+ function clipString(v, cap) {
32
+ if (typeof v !== 'string') return undefined;
33
+ if (v.length <= cap) return v;
34
+ return v.slice(0, cap);
35
+ }
36
+
37
+ /**
38
+ * @param {object} args
39
+ * @param {'session-start'|'post-tool-use'|'stop'} args.hook
40
+ * @param {Record<string, unknown>} args.event parsed stdin payload
41
+ * @param {string} [args.host] host_hint advisory
42
+ * @param {string} [args.sessionId] explicit override (CLI flag)
43
+ * @param {string} [args.notes] explicit override (CLI flag)
44
+ * @returns {{source: string, kind: string, payload: Record<string, unknown>, sessionIdHint: string|null}}
45
+ */
46
+ export function buildHookSubmission({ hook, event, host = 'claude-code', sessionId, notes }) {
47
+ const sessionIdHint =
48
+ sessionId ||
49
+ pickString(event.session_id) ||
50
+ pickString(event.sessionId) ||
51
+ null;
52
+
53
+ const toolName = pickString(event.tool_name) || pickString(event.toolName) || null;
54
+ const transcriptPath = pickString(event.transcript_path);
55
+
56
+ let kind;
57
+ let defaultNote;
58
+ switch (hook) {
59
+ case 'session-start':
60
+ kind = 'topic_shift';
61
+ defaultNote = 'Claude Code session started';
62
+ break;
63
+ case 'stop':
64
+ kind = 'conversation_end';
65
+ defaultNote = 'Claude Code session ended';
66
+ break;
67
+ case 'post-tool-use':
68
+ kind = mapToolToKind(toolName);
69
+ defaultNote = toolName
70
+ ? `Claude Code ${toolName} completed`
71
+ : 'Claude Code tool invocation completed';
72
+ break;
73
+ default:
74
+ throw new Error(`unknown hook: ${hook}`);
75
+ }
76
+
77
+ const conversationExcerpt = clipString(stringify(event.tool_response), TRUNC_EXCERPT);
78
+
79
+ const payload = stripUndefined({
80
+ notes: clipString(notes ?? defaultNote, TRUNC_NOTES),
81
+ session_id: sessionIdHint || undefined,
82
+ host_hint: host,
83
+ conversation_excerpt: conversationExcerpt,
84
+ tool_name: toolName ?? undefined,
85
+ transcript_path: transcriptPath,
86
+ confidence: hook === 'post-tool-use' ? 0.85 : 0.9,
87
+ client_checkpoint_id: pickString(event.client_checkpoint_id),
88
+ });
89
+
90
+ return {
91
+ source: 'cli_hook',
92
+ kind,
93
+ payload,
94
+ sessionIdHint,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * @param {string|null} toolName
100
+ */
101
+ function mapToolToKind(toolName) {
102
+ if (!toolName) return 'topic_shift';
103
+ const lower = toolName.toLowerCase();
104
+ if (lower === 'edit' || lower === 'write' || lower === 'multiedit' || lower === 'create') {
105
+ return 'artifact_produced';
106
+ }
107
+ if (lower === 'bash' || lower === 'shell' || lower === 'run') {
108
+ return 'task_completed';
109
+ }
110
+ return 'topic_shift';
111
+ }
112
+
113
+ function pickString(v) {
114
+ return typeof v === 'string' && v.length > 0 ? v : null;
115
+ }
116
+
117
+ function stringify(v) {
118
+ if (v == null) return undefined;
119
+ if (typeof v === 'string') return v;
120
+ try {
121
+ return JSON.stringify(v);
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /** @template T @param {T} obj @returns {T} */
128
+ function stripUndefined(obj) {
129
+ for (const k of Object.keys(obj)) {
130
+ if (obj[k] === undefined) delete obj[k];
131
+ }
132
+ return obj;
133
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // anyslate CLI dispatcher.
2
+ //
3
+ // anyslate hook <session-start|post-tool-use|stop>
4
+ // anyslate checkpoint --note "..."
5
+ // anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
6
+ // anyslate login --token <bearer>
7
+ // anyslate version
8
+ // anyslate help
9
+
10
+ import { runHook } from './commands/hook.mjs';
11
+ import { runCheckpoint } from './commands/checkpoint.mjs';
12
+ import { runUploadArtifact } from './commands/upload-artifact.mjs';
13
+ import { runLogin } from './commands/login.mjs';
14
+
15
+ const VERSION = '0.1.0';
16
+
17
+ const HELP = `anyslate ${VERSION}
18
+
19
+ usage:
20
+ anyslate hook <session-start|post-tool-use|stop> [--strict]
21
+ Submit a Claude Code lifecycle event to the AnySlate Activity feed.
22
+ Reads JSON event payload from stdin. Hooks fail open by default.
23
+
24
+ anyslate checkpoint --note "what happened" [--kind milestone] [--session <id>]
25
+ Submit an explicit checkpoint to the Activity feed (exits non-zero on
26
+ failure).
27
+
28
+ anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
29
+ Upload a file (or stdin) as an MCP artifact. Returns cloud://artifact/<id>.
30
+
31
+ anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]
32
+ Save credentials to ~/.anyslate/cli.json (mode 0600).
33
+
34
+ env:
35
+ ANYSLATE_API_URL override config (e.g. dev workers.dev URL)
36
+ ANYSLATE_MCP_TOKEN override config token
37
+ ANYSLATE_HANDLE override config handle
38
+ ANYSLATE_VERBOSE if set, hooks print JSON results to stdout
39
+ `;
40
+
41
+ /**
42
+ * @param {string[]} argv
43
+ * @returns {Promise<number>}
44
+ */
45
+ export async function main(argv) {
46
+ const cmd = argv[0];
47
+ switch (cmd) {
48
+ case 'hook':
49
+ return runHook(argv.slice(1));
50
+ case 'checkpoint':
51
+ return runCheckpoint(argv.slice(1));
52
+ case 'upload-artifact':
53
+ return runUploadArtifact(argv.slice(1));
54
+ case 'login':
55
+ return runLogin(argv.slice(1));
56
+ case 'version':
57
+ case '--version':
58
+ case '-v':
59
+ process.stdout.write(`${VERSION}\n`);
60
+ return 0;
61
+ case 'help':
62
+ case '--help':
63
+ case '-h':
64
+ case undefined:
65
+ process.stdout.write(HELP);
66
+ return 0;
67
+ default:
68
+ process.stderr.write(`anyslate: unknown command "${cmd}". Run \`anyslate help\` for usage.\n`);
69
+ return 2;
70
+ }
71
+ }
@@ -0,0 +1,174 @@
1
+ // Minimal JSON-RPC client for the AnySlate MCP service.
2
+ //
3
+ // The MCP service speaks the standard MCP JSON-RPC protocol. We only need
4
+ // `tools/call` from the CLI — that one method covers `activity_submit`,
5
+ // `checkpoint_session`, and `upload_artifact`.
6
+ //
7
+ // The server replies with `{ jsonrpc: '2.0', id, result | error }`. When the
8
+ // server returns an MCP `result.content[0].text` block we additionally
9
+ // JSON-parse it (the AnySlate dispatcher always returns JSON-encoded text).
10
+ //
11
+ // Streamable HTTP transport requires every non-initialize request to carry
12
+ // an `Mcp-Session-Id` header (mcp-protocol.ts:2566). The CLI is one-shot per
13
+ // invocation, so we run `initialize` first, capture the server-issued
14
+ // session id from the response header, and reuse it for the `tools/call`.
15
+
16
+ let nextRequestId = 1;
17
+
18
+ async function readBody(res) {
19
+ const ctype = res.headers.get('content-type') || '';
20
+ // The MCP service may stream JSON-RPC results as text/event-stream
21
+ // ("data: { ... }\n\n"). Handle both content types so we don't lose the
22
+ // payload on the SSE branch.
23
+ if (ctype.includes('text/event-stream')) {
24
+ const text = await res.text();
25
+ for (const line of text.split('\n')) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed.startsWith('data:')) continue;
28
+ const json = trimmed.slice(5).trim();
29
+ if (!json) continue;
30
+ try {
31
+ return JSON.parse(json);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ try {
39
+ return await res.json();
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ async function initializeSession({ url, token, fetchImpl, ac }) {
46
+ const res = await fetchImpl(url, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'content-type': 'application/json',
50
+ authorization: `Bearer ${token}`,
51
+ accept: 'application/json, text/event-stream',
52
+ 'mcp-protocol-version': '2025-06-18',
53
+ 'user-agent': 'anyslate-cli/0.1.0',
54
+ },
55
+ body: JSON.stringify({
56
+ jsonrpc: '2.0',
57
+ id: nextRequestId++,
58
+ method: 'initialize',
59
+ params: {
60
+ protocolVersion: '2025-06-18',
61
+ capabilities: {},
62
+ clientInfo: { name: 'anyslate-cli', version: '0.1.0' },
63
+ },
64
+ }),
65
+ signal: ac.signal,
66
+ });
67
+ const sid = res.headers.get('mcp-session-id');
68
+ if (!res.ok) {
69
+ const raw = await readBody(res);
70
+ return { ok: false, status: res.status, sessionId: null, raw };
71
+ }
72
+ if (!sid) {
73
+ return { ok: false, status: res.status, sessionId: null, raw: { error: 'server omitted Mcp-Session-Id header on initialize' } };
74
+ }
75
+ // Best-effort `notifications/initialized` — server returns 202 and we
76
+ // proceed regardless. Required by spec; harmless if dropped on the floor.
77
+ try {
78
+ await fetchImpl(url, {
79
+ method: 'POST',
80
+ headers: {
81
+ 'content-type': 'application/json',
82
+ authorization: `Bearer ${token}`,
83
+ accept: 'application/json, text/event-stream',
84
+ 'mcp-protocol-version': '2025-06-18',
85
+ 'mcp-session-id': sid,
86
+ 'user-agent': 'anyslate-cli/0.1.0',
87
+ },
88
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
89
+ signal: ac.signal,
90
+ });
91
+ } catch {
92
+ // Swallow — we have the session id; the notification is advisory.
93
+ }
94
+ return { ok: true, status: res.status, sessionId: sid, raw: null };
95
+ }
96
+
97
+ /**
98
+ * @param {object} opts
99
+ * @param {string} opts.apiUrl
100
+ * @param {string} opts.token Bearer token (MCP token).
101
+ * @param {string} opts.toolName
102
+ * @param {Record<string, unknown>} opts.args
103
+ * @param {typeof fetch} [opts.fetchImpl] override for tests
104
+ * @param {number} [opts.timeoutMs]
105
+ * @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown }>}
106
+ */
107
+ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetch, timeoutMs = 15000, sessionId: presetSessionId }) {
108
+ const url = `${apiUrl.replace(/\/+$/, '')}/mcp`;
109
+ const ac = new AbortController();
110
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
111
+
112
+ try {
113
+ let sessionId = presetSessionId || null;
114
+ if (!sessionId) {
115
+ const init = await initializeSession({ url, token, fetchImpl, ac });
116
+ if (!init.ok) {
117
+ // Surface the auth/init failure exactly as if the tool call itself failed,
118
+ // so the CLI's existing error handling (server 401 etc.) keeps working.
119
+ const errLike = init.raw && typeof init.raw === 'object' && 'error' in init.raw
120
+ ? init.raw.error
121
+ : init.raw && typeof init.raw === 'object' && 'message' in init.raw
122
+ ? init.raw
123
+ : { message: `initialize failed (HTTP ${init.status})` };
124
+ return { ok: false, status: init.status, data: errLike, raw: init.raw };
125
+ }
126
+ sessionId = init.sessionId;
127
+ }
128
+
129
+ const id = nextRequestId++;
130
+ const body = {
131
+ jsonrpc: '2.0',
132
+ method: 'tools/call',
133
+ id,
134
+ params: { name: toolName, arguments: args },
135
+ };
136
+
137
+ const res = await fetchImpl(url, {
138
+ method: 'POST',
139
+ headers: {
140
+ 'content-type': 'application/json',
141
+ authorization: `Bearer ${token}`,
142
+ accept: 'application/json, text/event-stream',
143
+ 'mcp-protocol-version': '2025-06-18',
144
+ 'mcp-session-id': sessionId,
145
+ 'user-agent': 'anyslate-cli/0.1.0',
146
+ },
147
+ body: JSON.stringify(body),
148
+ signal: ac.signal,
149
+ });
150
+
151
+ const status = res.status;
152
+ const raw = await readBody(res);
153
+
154
+ if (raw && typeof raw === 'object' && 'error' in raw && raw.error) {
155
+ return { ok: false, status, data: raw.error, raw };
156
+ }
157
+
158
+ // `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
159
+ const result = raw && typeof raw === 'object' ? raw.result : null;
160
+ const content = result && Array.isArray(result.content) ? result.content[0] : null;
161
+ if (content && content.type === 'text' && typeof content.text === 'string') {
162
+ try {
163
+ const parsed = JSON.parse(content.text);
164
+ return { ok: res.ok, status, data: parsed, raw };
165
+ } catch {
166
+ return { ok: res.ok, status, data: content.text, raw };
167
+ }
168
+ }
169
+
170
+ return { ok: res.ok, status, data: result, raw };
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ }
package/src/stdin.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // Read stdin to a single string with a soft cap.
2
+ //
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.
5
+
6
+ const MAX_STDIN_BYTES = 1_000_000;
7
+
8
+ /**
9
+ * @param {NodeJS.ReadableStream} [stream]
10
+ * @returns {Promise<string>}
11
+ */
12
+ export async function readStdin(stream = process.stdin) {
13
+ if (stream.isTTY) return '';
14
+ return new Promise((resolve, reject) => {
15
+ const chunks = [];
16
+ let bytes = 0;
17
+ stream.setEncoding('utf8');
18
+ stream.on('data', (chunk) => {
19
+ 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(''));
25
+ return;
26
+ }
27
+ chunks.push(chunk);
28
+ });
29
+ stream.on('end', () => resolve(chunks.join('')));
30
+ stream.on('error', reject);
31
+ });
32
+ }