@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/README.md +313 -19
- package/package.json +6 -6
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +61 -18
- package/src/commands/doctor.mjs +616 -0
- package/src/commands/hook.mjs +81 -23
- package/src/commands/login.mjs +362 -25
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +136 -26
- package/src/config.mjs +162 -13
- package/src/credentials.mjs +162 -0
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +61 -6
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- package/src/oauth.mjs +633 -0
- 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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Credential store for `~/.anyslate/cli.json`.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS ITS OWN MODULE. Before OAuth, exactly one command wrote this
|
|
4
|
+
// file (`login`), once, interactively. With refresh tokens, ANY command can
|
|
5
|
+
// write it — and hooks fire in parallel. Claude Code can run SessionStart,
|
|
6
|
+
// PostToolUse and Stop from separate processes within the same second, and a
|
|
7
|
+
// rotated refresh token that loses a write race is unrecoverable: the server
|
|
8
|
+
// has already marked the old one replaced (oauth_refresh_tokens.replaced_by_id),
|
|
9
|
+
// so the user silently drops to "re-login required" with no error anywhere.
|
|
10
|
+
//
|
|
11
|
+
// Two mechanisms, both required:
|
|
12
|
+
//
|
|
13
|
+
// 1. ATOMIC WRITE. Serialize to `cli.json.tmp-<pid>-<ts>` in the same
|
|
14
|
+
// directory, then rename(2) over the target. rename is atomic within a
|
|
15
|
+
// filesystem, so a concurrent reader sees either the whole old file or
|
|
16
|
+
// the whole new one — never a half-written one. Writing in place would
|
|
17
|
+
// let a reader observe a truncated file and conclude "no credentials".
|
|
18
|
+
//
|
|
19
|
+
// 2. READ-MODIFY-WRITE UNDER A LOCK. Every mutation re-reads from disk
|
|
20
|
+
// first and merges, so a racing process's rotated token survives. The
|
|
21
|
+
// lock (O_CREAT|O_EXCL, which is atomic) keeps two processes from both
|
|
22
|
+
// deciding to refresh and racing the server. Lock acquisition is
|
|
23
|
+
// best-effort with a stale-lock breaker: a crashed process must not wedge
|
|
24
|
+
// capture forever, so failing to take the lock proceeds unlocked rather
|
|
25
|
+
// than throwing.
|
|
26
|
+
|
|
27
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { anyslateDir } from './config.mjs';
|
|
30
|
+
|
|
31
|
+
export const CONFIG_FILE = 'cli.json';
|
|
32
|
+
export const LOCK_FILE = 'cli-refresh.lock';
|
|
33
|
+
|
|
34
|
+
/** A lock older than this belonged to a process that died holding it. */
|
|
35
|
+
export const LOCK_STALE_MS = 30_000;
|
|
36
|
+
export const LOCK_WAIT_MS = 10_000;
|
|
37
|
+
|
|
38
|
+
export function cliConfigPath(env = process.env) {
|
|
39
|
+
return join(anyslateDir(env), CONFIG_FILE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
44
|
+
* @returns {Record<string, any>}
|
|
45
|
+
*/
|
|
46
|
+
export function readConfigFile(env = process.env) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(cliConfigPath(env), 'utf8'));
|
|
49
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Serialize + rename. Never writes the target path in place.
|
|
57
|
+
*
|
|
58
|
+
* @param {Record<string, any>} next
|
|
59
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
60
|
+
* @returns {string} the path written
|
|
61
|
+
*/
|
|
62
|
+
export function writeConfigFile(next, env = process.env) {
|
|
63
|
+
const dir = anyslateDir(env);
|
|
64
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
const path = join(dir, CONFIG_FILE);
|
|
66
|
+
const tmp = join(dir, `${CONFIG_FILE}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(tmp);
|
|
73
|
+
} catch {
|
|
74
|
+
/* the temp file may not exist */
|
|
75
|
+
}
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read-modify-write. `mutate` receives the CURRENT on-disk object (not a
|
|
83
|
+
* snapshot the caller took earlier) and returns the object to persist.
|
|
84
|
+
*
|
|
85
|
+
* @param {(current: Record<string, any>) => Record<string, any>} mutate
|
|
86
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
87
|
+
* @returns {Record<string, any>} the persisted object
|
|
88
|
+
*/
|
|
89
|
+
export function updateConfigFile(mutate, env = process.env) {
|
|
90
|
+
const current = readConfigFile(env);
|
|
91
|
+
const next = mutate({ ...current }) ?? current;
|
|
92
|
+
writeConfigFile(next, env);
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run `fn` holding the refresh lock when possible.
|
|
100
|
+
*
|
|
101
|
+
* `fn` is given `{held}` so it can tell "I am the only refresher" from "I gave
|
|
102
|
+
* up waiting". It ALWAYS runs: a lock we could not take is a reason to be
|
|
103
|
+
* careful, never a reason to skip capture.
|
|
104
|
+
*
|
|
105
|
+
* @template T
|
|
106
|
+
* @param {(state: {held: boolean}) => Promise<T>|T} fn
|
|
107
|
+
* @param {{env?: NodeJS.ProcessEnv, waitMs?: number, staleMs?: number}} [opts]
|
|
108
|
+
* @returns {Promise<T>}
|
|
109
|
+
*/
|
|
110
|
+
export async function withRefreshLock(fn, opts = {}) {
|
|
111
|
+
const env = opts.env ?? process.env;
|
|
112
|
+
const waitMs = opts.waitMs ?? LOCK_WAIT_MS;
|
|
113
|
+
const staleMs = opts.staleMs ?? LOCK_STALE_MS;
|
|
114
|
+
|
|
115
|
+
let lockPath = null;
|
|
116
|
+
let held = false;
|
|
117
|
+
try {
|
|
118
|
+
const dir = anyslateDir(env);
|
|
119
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
120
|
+
lockPath = join(dir, LOCK_FILE);
|
|
121
|
+
} catch {
|
|
122
|
+
return fn({ held: false });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const deadline = Date.now() + waitMs;
|
|
126
|
+
while (Date.now() < deadline) {
|
|
127
|
+
try {
|
|
128
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(fd, `${process.pid} ${new Date().toISOString()}`);
|
|
131
|
+
} finally {
|
|
132
|
+
closeSync(fd);
|
|
133
|
+
}
|
|
134
|
+
held = true;
|
|
135
|
+
break;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (e?.code !== 'EEXIST') break; // unwritable dir — proceed unlocked
|
|
138
|
+
let broke = false;
|
|
139
|
+
try {
|
|
140
|
+
if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
|
|
141
|
+
unlinkSync(lockPath);
|
|
142
|
+
broke = true;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
/* the holder released it between stat and unlink — just retry */
|
|
146
|
+
}
|
|
147
|
+
if (!broke) await sleep(40);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
return await fn({ held });
|
|
153
|
+
} finally {
|
|
154
|
+
if (held) {
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(lockPath);
|
|
157
|
+
} catch {
|
|
158
|
+
/* already gone */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
128
|
-
|
|
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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// anyslate hook <session-start|post-tool-use|stop>
|
|
4
4
|
// anyslate checkpoint --note "..."
|
|
5
5
|
// anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
|
|
6
|
-
// anyslate login --token <bearer>
|
|
6
|
+
// anyslate login [--api-url <root>] | login --token <bearer>
|
|
7
|
+
// anyslate logout
|
|
8
|
+
// anyslate doctor
|
|
7
9
|
// anyslate version
|
|
8
10
|
// anyslate help
|
|
9
11
|
|
|
@@ -11,8 +13,13 @@ import { runHook } from './commands/hook.mjs';
|
|
|
11
13
|
import { runCheckpoint } from './commands/checkpoint.mjs';
|
|
12
14
|
import { runUploadArtifact } from './commands/upload-artifact.mjs';
|
|
13
15
|
import { runLogin } from './commands/login.mjs';
|
|
16
|
+
import { runLogout } from './commands/logout.mjs';
|
|
17
|
+
import { runDoctor } from './commands/doctor.mjs';
|
|
18
|
+
import { isCaptureDisabled, DISABLED_NOTICE } from './config.mjs';
|
|
19
|
+
import { VERSION } from './version.mjs';
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
/** Subcommands that talk to the network on the user's behalf (capture). */
|
|
22
|
+
const CAPTURE_COMMANDS = new Set(['hook', 'checkpoint', 'upload-artifact']);
|
|
16
23
|
|
|
17
24
|
const HELP = `anyslate ${VERSION}
|
|
18
25
|
|
|
@@ -26,16 +33,51 @@ usage:
|
|
|
26
33
|
failure).
|
|
27
34
|
|
|
28
35
|
anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
|
|
29
|
-
Upload a file (or stdin) as an MCP artifact.
|
|
36
|
+
Upload a UTF-8 file (or stdin, up to 5 MB) as an MCP artifact.
|
|
37
|
+
Prints cloud://artifact/<id>.
|
|
38
|
+
|
|
39
|
+
anyslate login [--api-url <URL>] [--no-browser] [--timeout <seconds>]
|
|
40
|
+
[--handle <ID>]
|
|
41
|
+
Sign in through your browser (OAuth 2.1 + PKCE). Opens the consent page,
|
|
42
|
+
waits on a loopback listener, then saves credentials to
|
|
43
|
+
~/.anyslate/cli.json (mode 0600). Defaults to production; --api-url takes
|
|
44
|
+
the service ROOT (no /mcp suffix) and every OAuth endpoint is read from
|
|
45
|
+
that root's discovery documents. --no-browser prints the URL instead of
|
|
46
|
+
opening it. --timeout is how long to wait for the callback (default 180).
|
|
47
|
+
The access token is refreshed automatically as it nears expiry.
|
|
30
48
|
|
|
31
49
|
anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]
|
|
32
|
-
|
|
50
|
+
[--force] [--no-verify]
|
|
51
|
+
Static-token sign-in, for CI and air-gapped setups. Verifies the token
|
|
52
|
+
against the server, then writes the same config file. Exits non-zero and
|
|
53
|
+
writes nothing if verification fails. --force writes anyway; --no-verify
|
|
54
|
+
skips the live check.
|
|
55
|
+
|
|
56
|
+
anyslate logout [--local]
|
|
57
|
+
Revoke the OAuth session server-side (best effort), then remove the
|
|
58
|
+
stored credentials. apiUrl, handle and the cached client registration are
|
|
59
|
+
kept. --local skips revocation and only clears the local file.
|
|
60
|
+
|
|
61
|
+
anyslate doctor [--deep] [--refresh]
|
|
62
|
+
Diagnose the whole setup: config layers, auth mode (OAuth vs static
|
|
63
|
+
token) and token expiry, token format, URL shape, reachability, token
|
|
64
|
+
validity, scopes, handle binding, PATH, Claude Code hook wiring, and the
|
|
65
|
+
last run outcome. Exits non-zero on any FAIL.
|
|
66
|
+
--deep additionally writes one probe row to your Activity feed.
|
|
67
|
+
--refresh forces a real OAuth refresh round trip (rotates the token).
|
|
33
68
|
|
|
34
69
|
env:
|
|
35
|
-
ANYSLATE_API_URL override config (e.g. dev workers.dev URL)
|
|
70
|
+
ANYSLATE_API_URL override config (service ROOT, e.g. dev workers.dev URL)
|
|
36
71
|
ANYSLATE_MCP_TOKEN override config token
|
|
37
|
-
ANYSLATE_HANDLE override config handle
|
|
72
|
+
ANYSLATE_HANDLE override config handle (handle ids are mh_-prefixed)
|
|
73
|
+
ANYSLATE_HOME override ~/.anyslate
|
|
38
74
|
ANYSLATE_VERBOSE if set, hooks print JSON results to stdout
|
|
75
|
+
ANYSLATE_STDIN_TIMEOUT_MS
|
|
76
|
+
idle timeout while reading stdin (default 10000; 0
|
|
77
|
+
disables). Raise it for very slow pipes.
|
|
78
|
+
ANYSLATE_DISABLE=1 kill switch: hook / checkpoint / upload-artifact make no
|
|
79
|
+
network call and exit 0. \`doctor\` and \`login\` still run
|
|
80
|
+
so you can diagnose and set up while capture is off.
|
|
39
81
|
`;
|
|
40
82
|
|
|
41
83
|
/**
|
|
@@ -44,6 +86,15 @@ env:
|
|
|
44
86
|
*/
|
|
45
87
|
export async function main(argv) {
|
|
46
88
|
const cmd = argv[0];
|
|
89
|
+
|
|
90
|
+
// The kill switch is enforced centrally so no capture subcommand can miss it.
|
|
91
|
+
// Each capture command also re-checks, which keeps them safe when imported
|
|
92
|
+
// directly (tests, embedding).
|
|
93
|
+
if (CAPTURE_COMMANDS.has(cmd) && isCaptureDisabled(process.env)) {
|
|
94
|
+
process.stderr.write(`${DISABLED_NOTICE}\n`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
47
98
|
switch (cmd) {
|
|
48
99
|
case 'hook':
|
|
49
100
|
return runHook(argv.slice(1));
|
|
@@ -53,6 +104,10 @@ export async function main(argv) {
|
|
|
53
104
|
return runUploadArtifact(argv.slice(1));
|
|
54
105
|
case 'login':
|
|
55
106
|
return runLogin(argv.slice(1));
|
|
107
|
+
case 'logout':
|
|
108
|
+
return runLogout(argv.slice(1));
|
|
109
|
+
case 'doctor':
|
|
110
|
+
return runDoctor(argv.slice(1));
|
|
56
111
|
case 'version':
|
|
57
112
|
case '--version':
|
|
58
113
|
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
|
+
}
|