lean-computer-use 0.0.0-stage → 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/AGENTS.md +71 -0
- package/CHANGELOG.md +18 -0
- package/CLA.md +37 -0
- package/CLAUDE.md +5 -0
- package/CODE_OF_CONDUCT.md +127 -0
- package/CONTRIBUTING.md +28 -0
- package/CREDITS.md +17 -0
- package/LICENSE +21 -0
- package/MAINTAINERS.md +8 -0
- package/README.md +233 -2
- package/RELEASING.md +49 -0
- package/REPO_SETTINGS.md +26 -0
- package/SECURITY.md +18 -0
- package/agents/desk-runner.md +48 -0
- package/bin/lean-computer-use.mjs +9 -0
- package/briefs/desk-brief.md +26 -0
- package/desk/README.md +64 -0
- package/desk/_resolve.py +279 -0
- package/desk/desk +33 -0
- package/desk/desk-clean +177 -0
- package/desk/desk-read +39 -0
- package/desk/exit-codes.json +13 -0
- package/docs/INSTALLER.md +62 -0
- package/docs/METHOD.md +17 -0
- package/docs/PROVENANCE.md +19 -0
- package/docs/README.md +12 -0
- package/docs/STACK.md +8 -0
- package/docs/cua-driver-install.md +57 -0
- package/docs/install.md +62 -0
- package/hooks/README.md +26 -0
- package/hooks/_common.py +172 -0
- package/hooks/native_batch_nudge.py +37 -0
- package/hooks/native_batch_nudge.sh +6 -0
- package/hooks/native_retry_guard.py +36 -0
- package/hooks/native_retry_guard.sh +8 -0
- package/hooks/settings-snippet.json +34 -0
- package/llms.txt +48 -0
- package/package.json +23 -4
- package/skills/lean-computer-use/SKILL.md +70 -0
- package/skills/lean-computer-use/methods/background-only.md +14 -0
- package/skills/lean-computer-use/methods/cheap-helper-or-subagent.md +18 -0
- package/skills/lean-computer-use/methods/cleanup-with-proof.md +24 -0
- package/skills/lean-computer-use/methods/desk-run.md +19 -0
- package/skills/lean-computer-use/methods/failure-modes.md +16 -0
- package/skills/lean-computer-use/methods/filtered-reads.md +30 -0
- package/skills/lean-computer-use/methods/same-app-chaining.md +20 -0
- package/skills/lean-computer-use/methods/verify.md +14 -0
- package/src/cli.mjs +33 -0
- package/src/doctor.mjs +87 -0
- package/src/hooks-register.mjs +56 -0
- package/src/install.mjs +291 -0
- package/src/measure.mjs +184 -0
- package/src/paths.mjs +81 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { packageRoot } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
export const help = `lean-computer-use: native Mac app control
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
lean-computer-use [install] [--dir PATH] [--share DIR] [--with-hooks] [--dry-run] [--yes]
|
|
9
|
+
lean-computer-use uninstall [--dir PATH] [--share DIR] [--dry-run]
|
|
10
|
+
lean-computer-use doctor [--json]
|
|
11
|
+
lean-computer-use measure [--projects-dir DIR] [--since YYYY-MM-DD] [--tool-pattern REGEX] [--json]
|
|
12
|
+
|
|
13
|
+
Aliases: --uninstall = uninstall; --dry = --dry-run
|
|
14
|
+
Options: --help, --version
|
|
15
|
+
Native app control needs macOS, Python 3 and cua-driver. The installer adds package files only.`;
|
|
16
|
+
|
|
17
|
+
/** @param {string[]} args @param {Record<string, any>} adapters */
|
|
18
|
+
export async function main(args = process.argv.slice(2), adapters = {}) {
|
|
19
|
+
const write = adapters.write || console.log;
|
|
20
|
+
if (args.length === 1 && args[0] === '--help') { write(help); return 0; }
|
|
21
|
+
if (args.length === 1 && args[0] === '--version') { write(JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version); return 0; }
|
|
22
|
+
let command = args[0] || 'install';
|
|
23
|
+
let rest = args.slice(1);
|
|
24
|
+
if (command === '--uninstall') command = 'uninstall';
|
|
25
|
+
else if (command.startsWith('--')) { command = 'install'; rest = args; }
|
|
26
|
+
if (command === 'install' || command === 'uninstall') {
|
|
27
|
+
const module = await import('./install.mjs');
|
|
28
|
+
return module[`${command}Command`](rest, { write, ...adapters.install });
|
|
29
|
+
}
|
|
30
|
+
if (command === 'doctor') return (await import('./doctor.mjs')).doctorCommand(rest, { write, ...adapters.doctor });
|
|
31
|
+
if (command === 'measure') return (await import('./measure.mjs')).measureCommand(rest, { write, ...adapters.measure });
|
|
32
|
+
throw Object.assign(new Error(`Unknown command: ${command}. Run lean-computer-use --help.`), { exitCode: 2 });
|
|
33
|
+
}
|
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { locations, payloadFiles, safePath, digest, packageRoot } from './paths.mjs';
|
|
5
|
+
import { hooksRegistered } from './hooks-register.mjs';
|
|
6
|
+
|
|
7
|
+
/** @param {{dir?: string, share?: string}} options @param {Record<string, any>} adapters */
|
|
8
|
+
export function doctor(options = {}, adapters = {}) {
|
|
9
|
+
const env = adapters.env || process.env;
|
|
10
|
+
const platform = adapters.platform || process.platform;
|
|
11
|
+
const version = adapters.nodeVersion || process.versions.node;
|
|
12
|
+
const source = adapters.source || packageRoot;
|
|
13
|
+
const roots = locations(options, env);
|
|
14
|
+
const run = adapters.run || ((command, args) => spawnSync(command, args, { env, encoding: 'utf8', timeout: 5000, windowsHide: true }));
|
|
15
|
+
const checks = [];
|
|
16
|
+
const add = (id, status, message, fix) => checks.push({ id, status, message, fix });
|
|
17
|
+
const command = (name, args) => {
|
|
18
|
+
try { return run(name, args); } catch (error) { return { status: null, stdout: '', stderr: error.message, error }; }
|
|
19
|
+
};
|
|
20
|
+
const text = result => `${result.stdout || ''}\n${result.stderr || ''}`.trim();
|
|
21
|
+
const node = Number.parseInt(version.split('.')[0], 10);
|
|
22
|
+
add('platform', platform === 'darwin' ? 'PASS' : 'WARN', platform === 'darwin' ? 'macOS native control supported' : 'skipped: macOS only', 'Run native control on macOS.');
|
|
23
|
+
add('node', node >= 22 ? 'PASS' : 'FAIL', `Node ${version}`, 'Use Node 22 or newer.');
|
|
24
|
+
const python = command('python3', ['--version']);
|
|
25
|
+
add('python', python.status === 0 ? 'PASS' : 'FAIL', python.status === 0 ? text(python) : 'python3 is unavailable', 'Install Python 3 and put python3 on PATH.');
|
|
26
|
+
if (platform !== 'darwin') {
|
|
27
|
+
for (const id of ['osascript', 'driver', 'daemon', 'permissions', 'telemetry']) add(id, 'WARN', 'skipped: macOS only', 'Run this check on macOS.');
|
|
28
|
+
} else {
|
|
29
|
+
const script = command('osascript', ['-e', 'return "ready"']);
|
|
30
|
+
add('osascript', script.status === 0 ? 'PASS' : 'FAIL', script.status === 0 ? 'osascript is available' : 'osascript is unavailable', 'Restore the macOS osascript command on PATH.');
|
|
31
|
+
const driver = command('cua-driver', ['--version']);
|
|
32
|
+
add('driver', driver.status === 0 ? 'PASS' : 'FAIL', driver.status === 0 ? text(driver) : 'cua-driver is unavailable', 'Follow docs/cua-driver-install.md and put cua-driver on PATH.');
|
|
33
|
+
const daemon = command('cua-driver', ['status']);
|
|
34
|
+
const stopped = /\b(stopped|not running|inactive|disconnected)\b/i.test(text(daemon));
|
|
35
|
+
add('daemon', daemon.status === 0 && !stopped ? 'PASS' : 'WARN', daemon.status === 0 && !stopped ? 'daemon is running' : 'daemon is stopped; desk starts it on demand', 'Run desk when native control is needed.');
|
|
36
|
+
const permissions = command('cua-driver', ['permissions', 'status', '--json']);
|
|
37
|
+
let granted = false;
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(permissions.stdout);
|
|
40
|
+
const state = value.permissions || value;
|
|
41
|
+
granted = permissions.status === 0 && state.accessibility === true && state.screen_recording === true;
|
|
42
|
+
} catch { /* An unreadable status is a failed permission check. */ }
|
|
43
|
+
add('permissions', granted ? 'PASS' : 'FAIL', granted ? 'Accessibility and Screen Recording are granted' : 'Accessibility or Screen Recording needs approval', 'Run cua-driver permissions grant, approve both permissions, then rerun doctor.');
|
|
44
|
+
const telemetry = command('cua-driver', ['telemetry', 'status']);
|
|
45
|
+
let disabled = telemetry.status === 0 && /\bdisabled\b/i.test(text(telemetry));
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(telemetry.stdout);
|
|
48
|
+
disabled = telemetry.status === 0 && (value.enabled === false || value.telemetry === false || value.status === 'disabled');
|
|
49
|
+
} catch { /* Text status is also supported. */ }
|
|
50
|
+
add('telemetry', disabled ? 'PASS' : 'WARN', disabled ? 'telemetry is disabled' : 'telemetry status needs attention', 'Run cua-driver telemetry disable, then cua-driver telemetry status.');
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const payload = payloadFiles(roots, source).filter(file => file.root === 'dir' || file.path.startsWith('desk/'));
|
|
54
|
+
const missing = payload.filter(file => {
|
|
55
|
+
try {
|
|
56
|
+
const path = safePath(roots[file.root], file.path);
|
|
57
|
+
return !existsSync(path) || digest(readFileSync(path)) !== digest(file.content);
|
|
58
|
+
} catch { return true; }
|
|
59
|
+
});
|
|
60
|
+
add('installed-files', missing.length ? 'FAIL' : 'PASS', missing.length ? `${missing.length} installed files are missing or changed` : 'skill, agent and desk files match the package', 'Run lean-computer-use install --yes with the same --dir and --share.');
|
|
61
|
+
} catch (error) {
|
|
62
|
+
add('installed-files', 'FAIL', `Package files cannot be checked: ${error.message}`, 'Reinstall the npm package, then run lean-computer-use install --yes.');
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const path = safePath(roots.dir, 'settings.json');
|
|
66
|
+
const settings = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
|
67
|
+
const registered = hooksRegistered(settings, join(roots.share, 'hooks'), source);
|
|
68
|
+
add('hooks', registered ? 'PASS' : 'WARN', registered ? 'optional hooks are registered' : 'optional hooks are not registered', 'Run lean-computer-use install --with-hooks --yes with the same --dir and --share.');
|
|
69
|
+
} catch {
|
|
70
|
+
add('hooks', 'WARN', 'optional hook registration could not be read', 'Review settings.json, then run lean-computer-use install --with-hooks --yes.');
|
|
71
|
+
}
|
|
72
|
+
return { checks, exitCode: checks.some(check => check.status === 'FAIL') ? 1 : 0 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string[]} args @param {Record<string, any>} adapters */
|
|
76
|
+
export function doctorCommand(args = [], adapters = {}) {
|
|
77
|
+
const options = { json: false };
|
|
78
|
+
for (const arg of args) {
|
|
79
|
+
if (arg === '--json') options.json = true;
|
|
80
|
+
else throw Object.assign(new Error(`Unknown option: ${arg}`), { exitCode: 2 });
|
|
81
|
+
}
|
|
82
|
+
const result = doctor(adapters.options || {}, adapters);
|
|
83
|
+
const write = adapters.write || console.log;
|
|
84
|
+
if (options.json) write(JSON.stringify(result, null, 2));
|
|
85
|
+
else for (const check of result.checks) write(`${check.status} ${check.id}: ${check.message}; fix: ${check.fix}`);
|
|
86
|
+
return result.exitCode;
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { packageRoot, shellQuote, safeRelative } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
export const hookMarker = 'lean-computer-use';
|
|
6
|
+
const ours = entry => entry?.marker === hookMarker;
|
|
7
|
+
|
|
8
|
+
export function registrations(hooksDir, source = packageRoot) {
|
|
9
|
+
const template = readFileSync(join(source, 'hooks/settings-snippet.json'), 'utf8');
|
|
10
|
+
const result = JSON.parse(template);
|
|
11
|
+
const events = result.hooks || result;
|
|
12
|
+
for (const entries of Object.values(events)) {
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
for (const hook of entry.hooks || []) {
|
|
15
|
+
if (!hook.command?.includes('__HOOKS_DIR__')) continue;
|
|
16
|
+
const match = /^(?:["'])?__HOOKS_DIR__\/([^"']+)(?:["'])?$/.exec(hook.command);
|
|
17
|
+
if (!match) throw new Error('Hook template must name one local shim');
|
|
18
|
+
hook.command = shellQuote(join(hooksDir, safeRelative(match[1])));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return events;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mergeHooks(settings, hooksDir, source = packageRoot) {
|
|
26
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Settings must be a JSON object');
|
|
27
|
+
const result = structuredClone(settings);
|
|
28
|
+
result.hooks ??= {};
|
|
29
|
+
if (!result.hooks || typeof result.hooks !== 'object' || Array.isArray(result.hooks)) throw new Error('Settings hooks must be an object');
|
|
30
|
+
for (const [event, entries] of Object.entries(registrations(hooksDir, source))) {
|
|
31
|
+
const current = result.hooks[event] || [];
|
|
32
|
+
if (!Array.isArray(current)) throw new Error(`Settings ${event} hooks must be an array`);
|
|
33
|
+
result.hooks[event] = [...current.filter(entry => !ours(entry)), ...entries.map(entry => ({ ...entry, marker: hookMarker }))];
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function removeHooks(settings) {
|
|
39
|
+
const result = structuredClone(settings);
|
|
40
|
+
if (!result?.hooks || typeof result.hooks !== 'object' || Array.isArray(result.hooks)) return result;
|
|
41
|
+
for (const [event, entries] of Object.entries(result.hooks)) {
|
|
42
|
+
if (!Array.isArray(entries)) continue;
|
|
43
|
+
const kept = entries.filter(entry => !ours(entry));
|
|
44
|
+
if (kept.length) result.hooks[event] = kept;
|
|
45
|
+
else delete result.hooks[event];
|
|
46
|
+
}
|
|
47
|
+
if (!Object.keys(result.hooks).length) delete result.hooks;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hooksRegistered(settings, hooksDir, source = packageRoot) {
|
|
52
|
+
const expected = registrations(hooksDir, source);
|
|
53
|
+
return Object.entries(expected).every(([event, entries]) => entries.every(entry =>
|
|
54
|
+
(settings?.hooks?.[event] || []).some(actual => ours(actual) && actual.matcher === entry.matcher &&
|
|
55
|
+
JSON.stringify(actual.hooks) === JSON.stringify(entry.hooks))));
|
|
56
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, lstatSync, chmodSync, unlinkSync, rmdirSync, readdirSync, openSync, closeSync, constants } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { locations, safePath, safeRelative, ensureDirectory, payloadFiles, digest, packageRoot, shellQuote } from './paths.mjs';
|
|
5
|
+
import { mergeHooks, removeHooks } from './hooks-register.mjs';
|
|
6
|
+
|
|
7
|
+
const manifestName = 'install-manifest.json';
|
|
8
|
+
const format = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
9
|
+
const hashFile = path => digest(readFileSync(path));
|
|
10
|
+
const fail = (message, exitCode = 2) => Object.assign(new Error(message), { exitCode });
|
|
11
|
+
|
|
12
|
+
function readJson(path, label) {
|
|
13
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
14
|
+
catch { throw fail(`Invalid ${label} JSON: ${path}. Repair the file before continuing.`); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function captureAnchors(roots) {
|
|
18
|
+
return Object.fromEntries(Object.entries(roots).map(([root, target]) => {
|
|
19
|
+
let path = target;
|
|
20
|
+
let parents = 0;
|
|
21
|
+
while (!existsSync(path)) { path = dirname(path); parents++; }
|
|
22
|
+
safePath(path);
|
|
23
|
+
const stat = lstatSync(path);
|
|
24
|
+
if (!stat.isDirectory()) throw fail(`Expected a directory: ${path}`);
|
|
25
|
+
return [root, { parents, identity: { dev: stat.dev, ino: stat.ino } }];
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseInstallArgs(args) {
|
|
30
|
+
/** @type {Record<string, any>} */
|
|
31
|
+
const options = { yes: false, dryRun: false, withHooks: false };
|
|
32
|
+
for (let index = 0; index < args.length; index++) {
|
|
33
|
+
const arg = args[index];
|
|
34
|
+
if (arg === '--yes') options.yes = true;
|
|
35
|
+
else if (arg === '--dry-run' || arg === '--dry') options.dryRun = true;
|
|
36
|
+
else if (arg === '--with-hooks') options.withHooks = true;
|
|
37
|
+
else if (arg === '--dir' || arg === '--share') {
|
|
38
|
+
const value = args[++index];
|
|
39
|
+
if (!value || value.startsWith('--')) throw fail(`Missing value for ${arg}`);
|
|
40
|
+
options[arg.slice(2)] = value;
|
|
41
|
+
} else throw fail(`Unknown option: ${arg}`);
|
|
42
|
+
}
|
|
43
|
+
return options;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validEntry(entry) {
|
|
47
|
+
if (!entry || !['dir', 'share'].includes(entry.root)) throw fail('Invalid manifest root');
|
|
48
|
+
safeRelative(entry.path);
|
|
49
|
+
const allowed = entry.root === 'dir'
|
|
50
|
+
? entry.path.startsWith('skills/lean-computer-use/') || entry.path === 'agents/desk-runner.md' || entry.path === 'settings.json'
|
|
51
|
+
: entry.path.startsWith('desk/') || entry.path.startsWith('hooks/');
|
|
52
|
+
if (!allowed || !/^[a-f0-9]{64}$/.test(entry.hash)) throw fail('Invalid manifest entry');
|
|
53
|
+
if (entry.retainedBackups && !Array.isArray(entry.retainedBackups)) throw fail('Invalid retained backups');
|
|
54
|
+
for (const backup of [entry.backup, ...(entry.retainedBackups || [])].filter(Boolean)) {
|
|
55
|
+
safeRelative(backup.path);
|
|
56
|
+
if (!backup.path.startsWith(`${entry.path}.bak`) || !/^[a-f0-9]{64}$/.test(backup.hash)) throw fail('Invalid manifest backup');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function directoryPath(roots, directory) {
|
|
61
|
+
if (!directory.parents) return safePath(roots[directory.root], directory.path);
|
|
62
|
+
if (directory.path || !Number.isInteger(directory.parents) || directory.parents < 1 || directory.parents > 64) throw fail('Invalid manifest parent directory');
|
|
63
|
+
let path = roots[directory.root];
|
|
64
|
+
for (let index = 0; index < directory.parents; index++) {
|
|
65
|
+
const parent = dirname(path);
|
|
66
|
+
if (parent === path || dirname(parent) === parent) throw fail('Filesystem root is not an install directory');
|
|
67
|
+
path = parent;
|
|
68
|
+
}
|
|
69
|
+
return safePath(path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readManifest(roots) {
|
|
73
|
+
const path = safePath(roots.share, manifestName);
|
|
74
|
+
if (!existsSync(path)) return null;
|
|
75
|
+
const manifest = readJson(path, 'manifest');
|
|
76
|
+
if (manifest.version !== 1 || manifest.roots?.dir !== roots.dir || manifest.roots?.share !== roots.share || !Array.isArray(manifest.files) || !Array.isArray(manifest.directories)) throw fail(`Invalid install manifest: ${path}`);
|
|
77
|
+
for (const root of ['dir', 'share']) {
|
|
78
|
+
const anchor = manifest.anchors?.[root];
|
|
79
|
+
if (!anchor || !Number.isInteger(anchor.parents) || anchor.parents < 0 || anchor.parents > 64 || typeof anchor.identity?.dev !== 'number' || typeof anchor.identity?.ino !== 'number') throw fail('Invalid install anchor');
|
|
80
|
+
let anchorPath = roots[root];
|
|
81
|
+
for (let index = 0; index < anchor.parents; index++) {
|
|
82
|
+
if (dirname(anchorPath) === anchorPath) throw fail('Invalid install anchor depth');
|
|
83
|
+
anchorPath = dirname(anchorPath);
|
|
84
|
+
}
|
|
85
|
+
safePath(anchorPath);
|
|
86
|
+
const stat = lstatSync(anchorPath);
|
|
87
|
+
if (!stat.isDirectory() || stat.dev !== anchor.identity.dev || stat.ino !== anchor.identity.ino) throw fail('Install anchor changed; review the manifest before uninstalling.');
|
|
88
|
+
}
|
|
89
|
+
manifest.files.forEach(validEntry);
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const entry of manifest.files) {
|
|
92
|
+
const id = `${entry.root}:${entry.path}`;
|
|
93
|
+
if (seen.has(id)) throw fail('Duplicate manifest entry');
|
|
94
|
+
seen.add(id);
|
|
95
|
+
safePath(roots[entry.root], entry.path);
|
|
96
|
+
if (entry.backup) safePath(roots[entry.root], entry.backup.path);
|
|
97
|
+
for (const backup of entry.retainedBackups || []) safePath(roots[entry.root], backup.path);
|
|
98
|
+
}
|
|
99
|
+
for (const directory of manifest.directories) {
|
|
100
|
+
if (!['dir', 'share'].includes(directory.root)) throw fail('Invalid manifest directory');
|
|
101
|
+
if (!directory.identity || typeof directory.identity.dev !== 'number' || typeof directory.identity.ino !== 'number') throw fail('Invalid manifest directory identity');
|
|
102
|
+
if (directory.parents && directory.parents >= manifest.anchors[directory.root].parents) throw fail('Manifest directory escapes its install anchor');
|
|
103
|
+
if (directory.path) safeRelative(directory.path);
|
|
104
|
+
const resolved = directoryPath(roots, directory);
|
|
105
|
+
const owned = directory.parents || resolved === roots[directory.root] || manifest.files.some(entry => {
|
|
106
|
+
const file = join(roots[entry.root], entry.path);
|
|
107
|
+
return file.startsWith(`${resolved}/`) || file.startsWith(`${resolved}\\`);
|
|
108
|
+
});
|
|
109
|
+
if (!owned) throw fail('Manifest directory is not owned');
|
|
110
|
+
}
|
|
111
|
+
return manifest;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writeChecked(path, content, mode) {
|
|
115
|
+
safePath(path);
|
|
116
|
+
if (existsSync(path) && !lstatSync(path).isFile()) throw fail(`Expected a regular file: ${path}`);
|
|
117
|
+
const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), mode);
|
|
118
|
+
try { writeFileSync(descriptor, content); } finally { closeSync(descriptor); }
|
|
119
|
+
chmodSync(path, mode);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function backupFor(roots, entry) {
|
|
123
|
+
const base = `${entry.path}.bak`;
|
|
124
|
+
let path = base;
|
|
125
|
+
let index = 1;
|
|
126
|
+
while (existsSync(safePath(roots[entry.root], path))) path = `${base}.${index++}`;
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function directoriesInside(roots, created) {
|
|
131
|
+
return created.flatMap(path => {
|
|
132
|
+
const stat = lstatSync(path);
|
|
133
|
+
const identity = { dev: stat.dev, ino: stat.ino };
|
|
134
|
+
for (const root of ['dir', 'share']) {
|
|
135
|
+
const rel = relative(roots[root], path);
|
|
136
|
+
if (!rel.startsWith('..') && !rel.startsWith('/') && !rel.includes(':')) return [{ root, path: rel.split('\\').join('/'), identity }];
|
|
137
|
+
let ancestor = roots[root];
|
|
138
|
+
for (let parents = 1; dirname(ancestor) !== ancestor; parents++) {
|
|
139
|
+
ancestor = dirname(ancestor);
|
|
140
|
+
if (ancestor === path) return [{ root, path: '', parents, identity }];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [];
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {string[]} args @param {Record<string, any>} adapters */
|
|
148
|
+
export async function installCommand(args = [], adapters = {}) {
|
|
149
|
+
const options = parseInstallArgs(args);
|
|
150
|
+
const write = adapters.write || console.log;
|
|
151
|
+
const roots = locations(options, adapters.env || process.env);
|
|
152
|
+
Object.values(roots).forEach(path => safePath(path));
|
|
153
|
+
const source = adapters.source || packageRoot;
|
|
154
|
+
const previous = readManifest(roots);
|
|
155
|
+
const anchors = previous?.anchors || captureAnchors(roots);
|
|
156
|
+
const files = payloadFiles(roots, source);
|
|
157
|
+
if (options.withHooks) {
|
|
158
|
+
const path = safePath(roots.dir, 'settings.json');
|
|
159
|
+
const current = existsSync(path) ? readJson(path, 'settings') : {};
|
|
160
|
+
files.push({ root: 'dir', path: 'settings.json', content: Buffer.from(format(mergeHooks(current, join(roots.share, 'hooks'), source))), mode: existsSync(path) ? lstatSync(path).mode & 0o777 : 0o600, settings: true });
|
|
161
|
+
}
|
|
162
|
+
const plan = files.map(file => {
|
|
163
|
+
const path = safePath(roots[file.root], file.path);
|
|
164
|
+
if (existsSync(path) && !lstatSync(path).isFile()) throw fail(`Expected a regular file: ${path}`);
|
|
165
|
+
const exists = existsSync(path);
|
|
166
|
+
return { ...file, absolute: path, hash: digest(file.content), action: !exists ? 'create' : hashFile(path) === digest(file.content) ? 'keep' : 'replace' };
|
|
167
|
+
});
|
|
168
|
+
write(`Install plan (${options.dryRun ? 'dry run' : 'preview'}):`);
|
|
169
|
+
for (const file of plan) write(`${file.action}: ${file.absolute}`);
|
|
170
|
+
write(`manifest: ${join(roots.share, manifestName)}`);
|
|
171
|
+
if (options.dryRun) return 0;
|
|
172
|
+
if (!options.yes && plan.some(file => file.action === 'replace')) throw fail('Replacing a differing file requires --yes. Review --dry-run first.');
|
|
173
|
+
if (!options.yes) {
|
|
174
|
+
const input = adapters.input || process.stdin;
|
|
175
|
+
const output = adapters.output || process.stdout;
|
|
176
|
+
if (!input.isTTY) throw fail('Install needs confirmation. Review --dry-run, then pass --yes.');
|
|
177
|
+
const prompt = createInterface({ input, output });
|
|
178
|
+
let answer;
|
|
179
|
+
try { answer = await prompt.question('Install these files? [y/N] '); } finally { prompt.close(); }
|
|
180
|
+
if (!/^y(es)?$/i.test(answer.trim())) { write('Install cancelled.'); return 0; }
|
|
181
|
+
}
|
|
182
|
+
const created = [];
|
|
183
|
+
const records = previous ? [...previous.files] : [];
|
|
184
|
+
for (const file of plan) {
|
|
185
|
+
const oldIndex = records.findIndex(entry => entry.root === file.root && entry.path === file.path);
|
|
186
|
+
const old = oldIndex >= 0 ? records[oldIndex] : null;
|
|
187
|
+
if (file.action === 'keep' && !old) continue;
|
|
188
|
+
let backup = old?.backup;
|
|
189
|
+
const retainedBackups = [...(old?.retainedBackups || [])];
|
|
190
|
+
if (file.action === 'replace') {
|
|
191
|
+
if (!backup || hashFile(file.absolute) !== old?.hash) {
|
|
192
|
+
const backupPath = backupFor(roots, file);
|
|
193
|
+
const original = readFileSync(safePath(roots[file.root], file.path));
|
|
194
|
+
const mode = lstatSync(file.absolute).mode & 0o777;
|
|
195
|
+
writeChecked(safePath(roots[file.root], backupPath), original, mode);
|
|
196
|
+
if (backup) retainedBackups.push(backup);
|
|
197
|
+
backup = { path: backupPath, hash: digest(original), mode };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (file.action !== 'keep') {
|
|
201
|
+
ensureDirectory(dirname(file.absolute), created);
|
|
202
|
+
writeChecked(safePath(roots[file.root], file.path), file.content, file.mode);
|
|
203
|
+
}
|
|
204
|
+
const record = { root: file.root, path: file.path, hash: file.hash, mode: file.mode, ...(backup ? { backup } : {}), ...(retainedBackups.length ? { retainedBackups } : {}), ...(file.settings ? { settings: true } : {}) };
|
|
205
|
+
if (oldIndex >= 0) records[oldIndex] = record;
|
|
206
|
+
else records.push(record);
|
|
207
|
+
}
|
|
208
|
+
ensureDirectory(roots.share, created);
|
|
209
|
+
const directories = [...(previous?.directories || []), ...directoriesInside(roots, created)];
|
|
210
|
+
const unique = [...new Map(directories.map(value => [`${value.root}:${value.path}:${value.parents || 0}`, value])).values()];
|
|
211
|
+
writeChecked(safePath(roots.share, manifestName), format({ version: 1, packageVersion: '0.1.0', roots, anchors, files: records, directories: unique }), 0o600);
|
|
212
|
+
if (!options.withHooks) {
|
|
213
|
+
write(`Optional hooks: ${join(roots.share, 'hooks/settings-snippet.json')}`);
|
|
214
|
+
write(`Merge hooks into ${join(roots.dir, 'settings.json')} with: lean-computer-use install --dir ${shellQuote(roots.dir)} --share ${shellQuote(roots.share)} --with-hooks --yes`);
|
|
215
|
+
}
|
|
216
|
+
write('Installed. Run lean-computer-use doctor.');
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** @param {string[]} args @param {Record<string, any>} adapters */
|
|
221
|
+
export async function uninstallCommand(args = [], adapters = {}) {
|
|
222
|
+
const options = parseInstallArgs(args);
|
|
223
|
+
const write = adapters.write || console.log;
|
|
224
|
+
const roots = locations(options, adapters.env || process.env);
|
|
225
|
+
const manifest = readManifest(roots);
|
|
226
|
+
if (!manifest) throw fail(`Install manifest not found: ${join(roots.share, manifestName)}`);
|
|
227
|
+
const actions = [];
|
|
228
|
+
for (const entry of manifest.files) {
|
|
229
|
+
for (const backup of entry.retainedBackups || []) write(`keep backup: ${safePath(roots[entry.root], backup.path)} (earlier user contents; restore manually if needed)`);
|
|
230
|
+
const path = safePath(roots[entry.root], entry.path);
|
|
231
|
+
if (!existsSync(path)) {
|
|
232
|
+
if (entry.backup) write(`keep backup: ${safePath(roots[entry.root], entry.backup.path)} (installed file is missing; restore manually if needed)`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!lstatSync(path).isFile()) throw fail(`Expected a regular file: ${path}`);
|
|
236
|
+
const matches = hashFile(path) === entry.hash;
|
|
237
|
+
if (matches) {
|
|
238
|
+
if (entry.backup) {
|
|
239
|
+
const backup = safePath(roots[entry.root], entry.backup.path);
|
|
240
|
+
if (!existsSync(backup) || hashFile(backup) !== entry.backup.hash) {
|
|
241
|
+
write(`keep: ${path} (backup changed or missing; restore manually)`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
actions.push({ entry, path, kind: 'restore', backup });
|
|
245
|
+
} else actions.push({ entry, path, kind: 'remove' });
|
|
246
|
+
} else if (entry.settings) {
|
|
247
|
+
const settings = readJson(path, 'settings');
|
|
248
|
+
const cleaned = removeHooks(settings);
|
|
249
|
+
if (entry.backup) write(`keep backup: ${safePath(roots[entry.root], entry.backup.path)} (settings changed; restore manually if needed)`);
|
|
250
|
+
actions.push({ entry, path, kind: 'unregister', content: format(cleaned) });
|
|
251
|
+
} else {
|
|
252
|
+
write(`keep: ${path} (user-edited file)`);
|
|
253
|
+
if (entry.backup) write(`keep backup: ${safePath(roots[entry.root], entry.backup.path)} (original user contents; restore manually if needed)`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const action of actions) write(`${action.kind}: ${action.path}`);
|
|
257
|
+
write(`remove manifest: ${join(roots.share, manifestName)}`);
|
|
258
|
+
write('For blocks pasted manually, remove only the entries marked lean-computer-use from your settings.');
|
|
259
|
+
if (options.dryRun) return 0;
|
|
260
|
+
for (const action of actions) {
|
|
261
|
+
const path = safePath(roots[action.entry.root], action.entry.path);
|
|
262
|
+
if (!existsSync(path)) continue;
|
|
263
|
+
if (action.kind === 'unregister') {
|
|
264
|
+
const current = readJson(path, 'settings');
|
|
265
|
+
writeChecked(path, format(removeHooks(current)), lstatSync(path).mode & 0o777);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (hashFile(path) !== action.entry.hash) { write(`keep: ${path} (changed during uninstall)`); continue; }
|
|
269
|
+
if (action.kind === 'restore') {
|
|
270
|
+
const backup = safePath(roots[action.entry.root], action.entry.backup.path);
|
|
271
|
+
if (hashFile(backup) !== action.entry.backup.hash) throw fail(`Backup changed: ${backup}`);
|
|
272
|
+
writeChecked(path, readFileSync(backup), action.entry.backup.mode || 0o644);
|
|
273
|
+
safePath(roots[action.entry.root], action.entry.backup.path);
|
|
274
|
+
unlinkSync(backup);
|
|
275
|
+
} else {
|
|
276
|
+
safePath(roots[action.entry.root], action.entry.path);
|
|
277
|
+
unlinkSync(path);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
safePath(roots.share, manifestName);
|
|
281
|
+
unlinkSync(join(roots.share, manifestName));
|
|
282
|
+
for (const directory of manifest.directories.sort((a, b) => directoryPath(roots, b).length - directoryPath(roots, a).length)) {
|
|
283
|
+
const path = directoryPath(roots, directory);
|
|
284
|
+
if (!existsSync(path)) continue;
|
|
285
|
+
const stat = lstatSync(path);
|
|
286
|
+
if (stat.dev !== directory.identity.dev || stat.ino !== directory.identity.ino) { write(`keep directory: ${path} (replaced since install)`); continue; }
|
|
287
|
+
if (stat.isDirectory() && readdirSync(path).length === 0) { directoryPath(roots, directory); rmdirSync(path); }
|
|
288
|
+
}
|
|
289
|
+
write('Uninstalled owned files.');
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
package/src/measure.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const defaultPattern = '^mcp__(computer-use|cua-driver)__';
|
|
6
|
+
const usageKeys = ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens'];
|
|
7
|
+
const number = value => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
8
|
+
|
|
9
|
+
function firstWord(command, start = 0) {
|
|
10
|
+
let value = '';
|
|
11
|
+
let quote = '';
|
|
12
|
+
let index = start;
|
|
13
|
+
while (/\s/.test(command[index] || '') && index < command.length) index++;
|
|
14
|
+
for (; index < command.length; index++) {
|
|
15
|
+
const character = command[index];
|
|
16
|
+
if (character === '\\' && quote !== "'" && index + 1 < command.length) { value += command[++index]; continue; }
|
|
17
|
+
if (quote) {
|
|
18
|
+
if (character === quote) quote = '';
|
|
19
|
+
else value += character;
|
|
20
|
+
} else if (character === '"' || character === "'") quote = character;
|
|
21
|
+
else if (/\s|[;&|<>]/.test(character)) break;
|
|
22
|
+
else value += character;
|
|
23
|
+
}
|
|
24
|
+
return { value, end: index };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function commandSegments(command) {
|
|
28
|
+
const segments = [];
|
|
29
|
+
let part = '';
|
|
30
|
+
let quote = '';
|
|
31
|
+
let escaped = false;
|
|
32
|
+
let comment = false;
|
|
33
|
+
let bodies = [];
|
|
34
|
+
for (let index = 0; index < command.length; index++) {
|
|
35
|
+
const character = command[index];
|
|
36
|
+
if (comment && character !== '\n') continue;
|
|
37
|
+
if (character === '\n') comment = false;
|
|
38
|
+
if (escaped) { part += character; escaped = false; continue; }
|
|
39
|
+
if (character === '\\' && quote !== "'") { part += character; escaped = true; continue; }
|
|
40
|
+
if (quote) { part += character; if (character === quote) quote = ''; continue; }
|
|
41
|
+
if (character === '"' || character === "'") { quote = character; part += character; continue; }
|
|
42
|
+
if (character === '#' && (index === 0 || /\s|[;&|]/.test(command[index - 1]))) { comment = true; continue; }
|
|
43
|
+
if (character === '<' && command[index + 1] === '<' && command[index - 1] !== '<' && command[index + 2] !== '<') {
|
|
44
|
+
const stripTabs = command[index + 2] === '-';
|
|
45
|
+
const marker = firstWord(command, index + (stripTabs ? 3 : 2));
|
|
46
|
+
if (marker.value) bodies.push({ marker: marker.value, stripTabs });
|
|
47
|
+
part += command.slice(index, marker.end);
|
|
48
|
+
index = marker.end - 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (/[;&|\n]/.test(character)) {
|
|
52
|
+
segments.push(part);
|
|
53
|
+
part = '';
|
|
54
|
+
if (character === '\n' && bodies.length) {
|
|
55
|
+
for (const body of bodies) {
|
|
56
|
+
while (index < command.length) {
|
|
57
|
+
const next = command.indexOf('\n', index + 1);
|
|
58
|
+
const end = next < 0 ? command.length : next;
|
|
59
|
+
const line = command.slice(index + 1, end);
|
|
60
|
+
index = end;
|
|
61
|
+
if ((body.stripTabs ? line.replace(/^\t+/, '') : line) === body.marker) break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
bodies = [];
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
part += character;
|
|
69
|
+
}
|
|
70
|
+
segments.push(part);
|
|
71
|
+
return segments;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isDeskCommand(command) {
|
|
75
|
+
if (typeof command !== 'string') return false;
|
|
76
|
+
return commandSegments(command).some(segment => ['desk', 'desk-read', 'desk-clean'].includes(firstWord(segment).value.split('/').pop()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function transcriptFiles(directory) {
|
|
80
|
+
if (!existsSync(directory)) return [];
|
|
81
|
+
return readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => {
|
|
82
|
+
if (entry.isSymbolicLink() || ['.git', 'node_modules', '.venv', 'dist', 'build'].includes(entry.name)) return [];
|
|
83
|
+
const path = join(directory, entry.name);
|
|
84
|
+
return entry.isDirectory() ? transcriptFiles(path) : entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function groupStats(messages) {
|
|
89
|
+
const totals = messages.map(message => message.total).sort((a, b) => a - b);
|
|
90
|
+
const steps = totals.length;
|
|
91
|
+
const total = totals.reduce((sum, value) => sum + value, 0);
|
|
92
|
+
const reread = messages.reduce((sum, message) => sum + message.reread, 0);
|
|
93
|
+
const middle = Math.floor(steps / 2);
|
|
94
|
+
return {
|
|
95
|
+
steps,
|
|
96
|
+
median: !steps ? 0 : steps % 2 ? totals[middle] : (totals[middle - 1] + totals[middle]) / 2,
|
|
97
|
+
p90: steps ? totals[Math.ceil(steps * 0.9) - 1] : 0,
|
|
98
|
+
mean: steps ? total / steps : 0,
|
|
99
|
+
total,
|
|
100
|
+
rereadShare: total ? reread / total : 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @param {{projectsDir?: string, since?: string, toolPattern?: string}} options */
|
|
105
|
+
export function measureTranscripts(options = {}) {
|
|
106
|
+
const directory = resolve(options.projectsDir || join(homedir(), '.claude/projects'));
|
|
107
|
+
const pattern = new RegExp(options.toolPattern || defaultPattern);
|
|
108
|
+
let since = null;
|
|
109
|
+
if (options.since) {
|
|
110
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.since) || !Number.isFinite(Date.parse(options.since)) || new Date(options.since).toISOString().slice(0, 10) !== options.since) throw new Error('--since needs a date in YYYY-MM-DD form');
|
|
111
|
+
since = Date.parse(options.since);
|
|
112
|
+
}
|
|
113
|
+
const files = transcriptFiles(directory);
|
|
114
|
+
const messages = new Map();
|
|
115
|
+
let malformedLines = 0;
|
|
116
|
+
for (const path of files) {
|
|
117
|
+
const lines = readFileSync(path, 'utf8').split(/\r?\n/);
|
|
118
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
119
|
+
const line = lines[lineIndex];
|
|
120
|
+
if (!line.trim()) continue;
|
|
121
|
+
let entry;
|
|
122
|
+
try { entry = JSON.parse(line); } catch { malformedLines++; continue; }
|
|
123
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
124
|
+
const message = entry.message;
|
|
125
|
+
if (entry.type !== 'assistant' && message?.role !== 'assistant') continue;
|
|
126
|
+
if (!message || !Array.isArray(message.content)) continue;
|
|
127
|
+
if (since !== null) {
|
|
128
|
+
const timestamp = Date.parse(entry.timestamp || message.timestamp || '');
|
|
129
|
+
if (!Number.isFinite(timestamp) || timestamp < since) continue;
|
|
130
|
+
}
|
|
131
|
+
const native = message.content.some(block => {
|
|
132
|
+
if (block?.type !== 'tool_use') return false;
|
|
133
|
+
pattern.lastIndex = 0;
|
|
134
|
+
return typeof block.name === 'string' && (pattern.test(block.name) || block.name === 'Bash' && isDeskCommand(block.input?.command));
|
|
135
|
+
});
|
|
136
|
+
const usage = message.usage || {};
|
|
137
|
+
const total = usageKeys.reduce((sum, key) => sum + number(usage[key]), 0);
|
|
138
|
+
const reread = number(usage.cache_creation_input_tokens) + number(usage.cache_read_input_tokens);
|
|
139
|
+
const id = typeof message.id === 'string' && message.id ? message.id : `${path}:${lineIndex}`;
|
|
140
|
+
const current = messages.get(id);
|
|
141
|
+
const value = { total, reread, native: native || current?.native || false, sidechain: Boolean(entry.isSidechain) };
|
|
142
|
+
if (!current || total > current.total) messages.set(id, value);
|
|
143
|
+
else if (native) current.native = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const values = [...messages.values()].filter(message => message.native);
|
|
147
|
+
return {
|
|
148
|
+
files: files.length,
|
|
149
|
+
malformedLines,
|
|
150
|
+
main: groupStats(values.filter(message => !message.sidechain)),
|
|
151
|
+
subagent: groupStats(values.filter(message => message.sidechain)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function parseMeasureArgs(args) {
|
|
156
|
+
/** @type {Record<string, any>} */
|
|
157
|
+
const options = { json: false };
|
|
158
|
+
for (let index = 0; index < args.length; index++) {
|
|
159
|
+
const arg = args[index];
|
|
160
|
+
if (arg === '--json') options.json = true;
|
|
161
|
+
else if (['--projects-dir', '--since', '--tool-pattern'].includes(arg)) {
|
|
162
|
+
const value = args[++index];
|
|
163
|
+
if (!value || value.startsWith('--')) throw Object.assign(new Error(`Missing value for ${arg}`), { exitCode: 2 });
|
|
164
|
+
options[{ '--projects-dir': 'projectsDir', '--since': 'since', '--tool-pattern': 'toolPattern' }[arg]] = value;
|
|
165
|
+
} else throw Object.assign(new Error(`Unknown option: ${arg}`), { exitCode: 2 });
|
|
166
|
+
}
|
|
167
|
+
return options;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {string[]} args @param {{write?: (line: string) => void}} adapters */
|
|
171
|
+
export function measureCommand(args = [], adapters = {}) {
|
|
172
|
+
const options = parseMeasureArgs(args);
|
|
173
|
+
const write = adapters.write || console.log;
|
|
174
|
+
const result = measureTranscripts(options);
|
|
175
|
+
if (options.json) { write(JSON.stringify(result, null, 2)); return 0; }
|
|
176
|
+
if (result.main.steps + result.subagent.steps === 0) { write(`0 native-app steps found in ${result.files} files`); return 0; }
|
|
177
|
+
write('Group Steps Median P90 Mean Total Re-read');
|
|
178
|
+
for (const group of ['main', 'subagent']) {
|
|
179
|
+
const value = result[group];
|
|
180
|
+
write(`${group.padEnd(9)} ${value.steps} ${value.median} ${value.p90} ${Number(value.mean.toFixed(1))} ${value.total} ${(value.rereadShare * 100).toFixed(1)}%`);
|
|
181
|
+
}
|
|
182
|
+
write(`Files: ${result.files}; malformed lines skipped: ${result.malformedLines}`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|