@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/hooks.mjs CHANGED
@@ -1,15 +1,44 @@
1
1
  // Hook payload shaping.
2
2
  //
3
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
4
+ // missing / empty / non-JSON stdin is fine - we still produce a valid
5
5
  // activity_submit payload, just with less context. This keeps the CLI from
6
6
  // breaking a Claude Code session because of an unexpected hook shape.
7
7
  //
8
8
  // This module is pure (no I/O) so it can be unit-tested directly.
9
+ //
10
+ // ---------------------------------------------------------------------------
11
+ // W13 — structured activity ledger
12
+ // ---------------------------------------------------------------------------
13
+ // Live testing showed a hook-style note ("Claude Code Bash completed") merges
14
+ // as a no-op: decisions_added 0, tasks_added 0, tasks_completed 0. The
15
+ // extractor is correct — the note genuinely contains nothing.
16
+ //
17
+ // So hooks now emit structured FACTS as first-class payload fields, derived
18
+ // from the Claude Code event (tool_name + tool_input + tool_response):
19
+ //
20
+ // files_touched : string[]
21
+ // commands_run : string[]
22
+ // exit_status : string | number
23
+ //
24
+ // INVARIANT: these are machine-derived observations and belong in a dedicated
25
+ // activity/ledger section of the memory page. They must NEVER be folded into
26
+ // Decisions or Open Tasks, which stay human/LLM-authored. That is why they are
27
+ // separate fields and are deliberately NOT concatenated into `notes` — `notes`
28
+ // is the prose channel the extractor mines for decisions and tasks, and
29
+ // polluting it would manufacture fake decisions out of shell commands.
30
+ //
31
+ // There is no LLM call anywhere on this path (the Cost Governor is untouched).
9
32
 
10
33
  const TRUNC_NOTES = 4_000;
11
34
  const TRUNC_EXCERPT = 16_000;
12
35
 
36
+ /** Caps for the structured ledger fields — keep the payload bounded. */
37
+ const MAX_FILES = 50;
38
+ const MAX_COMMANDS = 20;
39
+ const MAX_COMMAND_CHARS = 2_000;
40
+ const MAX_PATH_CHARS = 512;
41
+
13
42
  /**
14
43
  * @param {string|null|undefined} raw
15
44
  * @returns {Record<string, unknown>}
@@ -34,6 +63,99 @@ function clipString(v, cap) {
34
63
  return v.slice(0, cap);
35
64
  }
36
65
 
66
+ /**
67
+ * File paths the tool call touched.
68
+ *
69
+ * @param {unknown} toolInput
70
+ * @param {unknown} toolResponse
71
+ * @returns {string[]}
72
+ */
73
+ export function extractFilesTouched(toolInput, toolResponse) {
74
+ const out = [];
75
+ const push = (v) => {
76
+ if (typeof v !== 'string') return;
77
+ const s = v.trim();
78
+ if (!s || s.length > MAX_PATH_CHARS) return;
79
+ if (!out.includes(s)) out.push(s);
80
+ };
81
+
82
+ const scan = (obj) => {
83
+ if (!obj || typeof obj !== 'object') return;
84
+ for (const key of ['file_path', 'filePath', 'path', 'notebook_path', 'notebookPath']) {
85
+ push(obj[key]);
86
+ }
87
+ for (const key of ['file_paths', 'filePaths', 'paths', 'files']) {
88
+ const arr = obj[key];
89
+ if (Array.isArray(arr)) for (const v of arr) push(typeof v === 'string' ? v : v?.file_path);
90
+ }
91
+ // MultiEdit-style: edits: [{ file_path, ... }]
92
+ const edits = obj.edits ?? obj.changes;
93
+ if (Array.isArray(edits)) for (const e of edits) push(e?.file_path ?? e?.filePath ?? e?.path);
94
+ };
95
+
96
+ scan(toolInput);
97
+ scan(toolResponse);
98
+ return out.slice(0, MAX_FILES);
99
+ }
100
+
101
+ /**
102
+ * Shell commands the tool call ran.
103
+ *
104
+ * @param {unknown} toolInput
105
+ * @returns {string[]}
106
+ */
107
+ export function extractCommandsRun(toolInput) {
108
+ if (!toolInput || typeof toolInput !== 'object') return [];
109
+ const out = [];
110
+ const push = (v) => {
111
+ if (typeof v !== 'string') return;
112
+ const s = v.trim();
113
+ if (!s) return;
114
+ const clipped = s.length > MAX_COMMAND_CHARS ? s.slice(0, MAX_COMMAND_CHARS) : s;
115
+ if (!out.includes(clipped)) out.push(clipped);
116
+ };
117
+ push(toolInput.command);
118
+ push(toolInput.cmd);
119
+ push(toolInput.script);
120
+ const arr = toolInput.commands;
121
+ if (Array.isArray(arr)) for (const v of arr) push(v);
122
+ return out.slice(0, MAX_COMMANDS);
123
+ }
124
+
125
+ /**
126
+ * Normalized outcome of the tool call. Returns `undefined` when the event
127
+ * carries no outcome signal at all — an absent field is honest, `"unknown"`
128
+ * is noise.
129
+ *
130
+ * @param {unknown} toolResponse
131
+ * @returns {string|number|undefined}
132
+ */
133
+ export function extractExitStatus(toolResponse) {
134
+ if (toolResponse == null) return undefined;
135
+ if (typeof toolResponse !== 'object') return undefined;
136
+
137
+ for (const key of ['exit_code', 'exitCode', 'returncode', 'returnCode', 'status_code']) {
138
+ const v = toolResponse[key];
139
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
140
+ if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v);
141
+ }
142
+ for (const key of ['is_error', 'isError', 'error']) {
143
+ const v = toolResponse[key];
144
+ if (v === true) return 'error';
145
+ if (typeof v === 'string' && v.trim()) return 'error';
146
+ }
147
+ for (const key of ['success', 'ok']) {
148
+ const v = toolResponse[key];
149
+ if (v === true) return 'success';
150
+ if (v === false) return 'error';
151
+ }
152
+ const status = toolResponse.status;
153
+ if (typeof status === 'string' && status.trim()) return status.trim();
154
+ if (typeof status === 'number' && Number.isFinite(status)) return status;
155
+ if (toolResponse.is_error === false || toolResponse.isError === false) return 'success';
156
+ return undefined;
157
+ }
158
+
37
159
  /**
38
160
  * @param {object} args
39
161
  * @param {'session-start'|'post-tool-use'|'stop'} args.hook
@@ -52,6 +174,8 @@ export function buildHookSubmission({ hook, event, host = 'claude-code', session
52
174
 
53
175
  const toolName = pickString(event.tool_name) || pickString(event.toolName) || null;
54
176
  const transcriptPath = pickString(event.transcript_path);
177
+ const toolInput = event.tool_input ?? event.toolInput ?? null;
178
+ const toolResponse = event.tool_response ?? event.toolResponse ?? null;
55
179
 
56
180
  let kind;
57
181
  let defaultNote;
@@ -74,9 +198,26 @@ export function buildHookSubmission({ hook, event, host = 'claude-code', session
74
198
  throw new Error(`unknown hook: ${hook}`);
75
199
  }
76
200
 
77
- const conversationExcerpt = clipString(stringify(event.tool_response), TRUNC_EXCERPT);
201
+ const conversationExcerpt = clipString(stringify(toolResponse), TRUNC_EXCERPT);
202
+
203
+ // W13 — structured ledger facts. Never merged into `notes`.
204
+ const filesTouched = extractFilesTouched(toolInput, toolResponse);
205
+ const commandsRun = extractCommandsRun(toolInput);
206
+ const exitStatus = extractExitStatus(toolResponse);
78
207
 
79
- const payload = stripUndefined({
208
+ // Session boundaries are real ledger entries — "started work in /repo/x",
209
+ // "session ended" — not content-free pings. Without a ledger field the
210
+ // server classifies them as liveness-only and files them as `failed`, which
211
+ // would put two alarming rows in the user's Activity panel for every single
212
+ // Claude Code session, forever. `cwd` is what makes them legible on the
213
+ // page (it names the repo the work happened in).
214
+ const cwd =
215
+ pickString(event.cwd) ||
216
+ pickString(event.working_directory) ||
217
+ pickString(event.workingDirectory) ||
218
+ (typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : null);
219
+
220
+ const payload = stripEmpty({
80
221
  notes: clipString(notes ?? defaultNote, TRUNC_NOTES),
81
222
  session_id: sessionIdHint || undefined,
82
223
  host_hint: host,
@@ -85,6 +226,10 @@ export function buildHookSubmission({ hook, event, host = 'claude-code', session
85
226
  transcript_path: transcriptPath,
86
227
  confidence: hook === 'post-tool-use' ? 0.85 : 0.9,
87
228
  client_checkpoint_id: pickString(event.client_checkpoint_id),
229
+ files_touched: filesTouched.length ? filesTouched : undefined,
230
+ commands_run: commandsRun.length ? commandsRun : undefined,
231
+ exit_status: exitStatus,
232
+ cwd: cwd || undefined,
88
233
  });
89
234
 
90
235
  return {
@@ -102,9 +247,20 @@ function mapToolToKind(toolName) {
102
247
  if (!toolName) return 'topic_shift';
103
248
  const lower = toolName.toLowerCase();
104
249
  if (lower === 'edit' || lower === 'write' || lower === 'multiedit' || lower === 'create') {
105
- return 'artifact_produced';
250
+ // NOT `artifact_produced`. That kind is a contract: "I stored an artifact,
251
+ // here is its ref in artifact_refs". A lifecycle hook stores nothing — it
252
+ // only observes that a file was touched, which W13 now carries properly in
253
+ // the `files_touched` ledger field.
254
+ //
255
+ // Claiming `artifact_produced` with an empty `artifact_refs` is precisely
256
+ // the "agent said it produced a file it never stored" shape that the
257
+ // server-side guard (activity-promote.ts:317) rejects, so every approved
258
+ // edit would have died with `artifact_refs_required`. It also tripped
259
+ // classifier rule #1, forcing a manual approval for every single edit —
260
+ // the busiest event in a Claude Code session.
261
+ return 'task_completed';
106
262
  }
107
- if (lower === 'bash' || lower === 'shell' || lower === 'run') {
263
+ if (lower === 'bash' || lower === 'shell' || lower === 'run' || lower === 'gitcommit' || lower === 'git_commit' || lower === 'git commit') {
108
264
  return 'task_completed';
109
265
  }
110
266
  return 'topic_shift';
@@ -124,10 +280,16 @@ function stringify(v) {
124
280
  }
125
281
  }
126
282
 
127
- /** @template T @param {T} obj @returns {T} */
128
- function stripUndefined(obj) {
283
+ /**
284
+ * Drop both `undefined` AND `null`. `pickString` yields `null` for absent
285
+ * fields, so `transcript_path: null` and `client_checkpoint_id: null` used to
286
+ * leak onto every single submission.
287
+ *
288
+ * @template T @param {T} obj @returns {T}
289
+ */
290
+ function stripEmpty(obj) {
129
291
  for (const k of Object.keys(obj)) {
130
- if (obj[k] === undefined) delete obj[k];
292
+ if (obj[k] === undefined || obj[k] === null) delete obj[k];
131
293
  }
132
294
  return obj;
133
295
  }
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // anyslate checkpoint --note "..."
5
5
  // anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
6
6
  // anyslate login --token <bearer>
7
+ // anyslate doctor
7
8
  // anyslate version
8
9
  // anyslate help
9
10
 
@@ -11,8 +12,12 @@ import { runHook } from './commands/hook.mjs';
11
12
  import { runCheckpoint } from './commands/checkpoint.mjs';
12
13
  import { runUploadArtifact } from './commands/upload-artifact.mjs';
13
14
  import { runLogin } from './commands/login.mjs';
15
+ import { runDoctor } from './commands/doctor.mjs';
16
+ import { isCaptureDisabled, DISABLED_NOTICE } from './config.mjs';
17
+ import { VERSION } from './version.mjs';
14
18
 
15
- const VERSION = '0.1.0';
19
+ /** Subcommands that talk to the network on the user's behalf (capture). */
20
+ const CAPTURE_COMMANDS = new Set(['hook', 'checkpoint', 'upload-artifact']);
16
21
 
17
22
  const HELP = `anyslate ${VERSION}
18
23
 
@@ -26,16 +31,34 @@ usage:
26
31
  failure).
27
32
 
28
33
  anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
29
- Upload a file (or stdin) as an MCP artifact. Returns cloud://artifact/<id>.
34
+ Upload a UTF-8 file (or stdin, up to 5 MB) as an MCP artifact.
35
+ Prints cloud://artifact/<id>.
30
36
 
31
37
  anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]
32
- Save credentials to ~/.anyslate/cli.json (mode 0600).
38
+ [--force] [--no-verify]
39
+ Verify the token against the server, then save credentials to
40
+ ~/.anyslate/cli.json (mode 0600). Exits non-zero and writes nothing if
41
+ verification fails. --api-url takes the service ROOT (no /mcp suffix).
42
+ --force writes anyway; --no-verify skips the live check.
43
+
44
+ anyslate doctor [--deep]
45
+ Diagnose the whole setup: config layers, token format, URL shape,
46
+ reachability, token validity, scopes, handle binding, PATH, Claude Code
47
+ hook wiring, and the last run outcome. Exits non-zero on any FAIL.
48
+ --deep additionally writes one probe row to your Activity feed.
33
49
 
34
50
  env:
35
- ANYSLATE_API_URL override config (e.g. dev workers.dev URL)
51
+ ANYSLATE_API_URL override config (service ROOT, e.g. dev workers.dev URL)
36
52
  ANYSLATE_MCP_TOKEN override config token
37
- ANYSLATE_HANDLE override config handle
53
+ ANYSLATE_HANDLE override config handle (handle ids are mh_-prefixed)
54
+ ANYSLATE_HOME override ~/.anyslate
38
55
  ANYSLATE_VERBOSE if set, hooks print JSON results to stdout
56
+ ANYSLATE_STDIN_TIMEOUT_MS
57
+ idle timeout while reading stdin (default 10000; 0
58
+ disables). Raise it for very slow pipes.
59
+ ANYSLATE_DISABLE=1 kill switch: hook / checkpoint / upload-artifact make no
60
+ network call and exit 0. \`doctor\` and \`login\` still run
61
+ so you can diagnose and set up while capture is off.
39
62
  `;
40
63
 
41
64
  /**
@@ -44,6 +67,15 @@ env:
44
67
  */
45
68
  export async function main(argv) {
46
69
  const cmd = argv[0];
70
+
71
+ // The kill switch is enforced centrally so no capture subcommand can miss it.
72
+ // Each capture command also re-checks, which keeps them safe when imported
73
+ // directly (tests, embedding).
74
+ if (CAPTURE_COMMANDS.has(cmd) && isCaptureDisabled(process.env)) {
75
+ process.stderr.write(`${DISABLED_NOTICE}\n`);
76
+ return 0;
77
+ }
78
+
47
79
  switch (cmd) {
48
80
  case 'hook':
49
81
  return runHook(argv.slice(1));
@@ -53,6 +85,8 @@ export async function main(argv) {
53
85
  return runUploadArtifact(argv.slice(1));
54
86
  case 'login':
55
87
  return runLogin(argv.slice(1));
88
+ case 'doctor':
89
+ return runDoctor(argv.slice(1));
56
90
  case 'version':
57
91
  case '--version':
58
92
  case '-v':
package/src/io.mjs ADDED
@@ -0,0 +1,30 @@
1
+ // Injectable stdout/stderr.
2
+ //
3
+ // Commands must never reach for `process.stdout` directly: tests that
4
+ // monkeypatch it also swallow the test reporter's own output, which silently
5
+ // hides results. Every command takes `{stdout, stderr}` in its deps and
6
+ // defaults to the real streams.
7
+
8
+ /**
9
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
10
+ */
11
+ export function makeIo(deps = {}) {
12
+ return {
13
+ out: deps.stdout ?? process.stdout,
14
+ err: deps.stderr ?? process.stderr,
15
+ };
16
+ }
17
+
18
+ /** A collector usable anywhere an io stream is expected. */
19
+ export function memoryStream() {
20
+ let text = '';
21
+ return {
22
+ write(chunk) {
23
+ text += String(chunk);
24
+ return true;
25
+ },
26
+ get text() {
27
+ return text;
28
+ },
29
+ };
30
+ }