mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
refactor: split CLI backend module
Change-Id: I7450dface429972b71b173973e6f71393d83303e
This commit is contained in:
parent
206a50f8d0
commit
5f6f18677b
4 changed files with 166 additions and 156 deletions
48
main.js
48
main.js
File diff suppressed because one or more lines are too long
134
main.ts
134
main.ts
|
|
@ -1,12 +1,9 @@
|
|||
'use strict';
|
||||
import { Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, Modal, MarkdownRenderer, requestUrl, setIcon } from 'obsidian';
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { findLineForAnchor } from './src/anchor';
|
||||
import { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './src/cache';
|
||||
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './src/cards';
|
||||
import { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './src/cli';
|
||||
import { translate } from './src/i18n';
|
||||
import { cardToMarkdown, cardToPlain, cardsToMarkdown } from './src/markdown';
|
||||
import { activeSectionLine, nextCardIndex } from './src/navigation';
|
||||
|
|
@ -15,7 +12,6 @@ import { ensureVaultFolder, folderPathsForTarget, normalizeVaultPath } from './s
|
|||
import {
|
||||
extractJson,
|
||||
normalizeCardsPayload,
|
||||
parseCardsJson,
|
||||
} from './src/schema';
|
||||
import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
|
||||
import {
|
||||
|
|
@ -57,133 +53,6 @@ import {
|
|||
|
||||
const VIEW_TYPE_PARALLEL = 'parallel-reader-view';
|
||||
|
||||
/* CLI discovery: Obsidian launched from GUI doesn't inherit shell PATH */
|
||||
function resolveCliPath(name, override) {
|
||||
if (override && override.trim()) return override.trim();
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, 'bin', name), // user-maintained (take precedence)
|
||||
path.join(home, '.local/bin', name),
|
||||
path.join(home, '.claude/local', name),
|
||||
path.join(home, '.codex/bin', name),
|
||||
path.join(home, '.bun/bin', name),
|
||||
path.join(home, '.npm-global/bin', name),
|
||||
path.join(home, '.cargo/bin', name),
|
||||
'/opt/homebrew/bin/' + name, // homebrew (apple silicon)
|
||||
'/usr/local/bin/' + name, // may be stale on mac — last resort
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try { if (fs.existsSync(p)) return p; } catch (_) {}
|
||||
}
|
||||
return name; // fall back to PATH lookup
|
||||
}
|
||||
|
||||
function runCli(cmd, args, stdinText, timeoutMs, job?) {
|
||||
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
let child;
|
||||
let settled = false;
|
||||
let timer;
|
||||
const fail = err => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
const succeed = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// ensure common install paths are in PATH for sub-spawns
|
||||
PATH: [
|
||||
process.env.PATH || '',
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
path.join(os.homedir(), '.local/bin'),
|
||||
path.join(os.homedir(), '.claude/local'),
|
||||
].filter(Boolean).join(':'),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return reject(new Error(`Failed to start ${cmd}: ${e.message}`));
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
timer = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch (_) {}
|
||||
fail(new Error(`CLI timed out (${timeoutMs}ms)`));
|
||||
}, timeoutMs);
|
||||
if (job) {
|
||||
job.onCancel(() => {
|
||||
try { child.kill('SIGKILL'); } catch (_) {}
|
||||
fail(new GenerationJobCancelledError(job.key));
|
||||
});
|
||||
}
|
||||
|
||||
child.stdout.on('data', d => { stdout += d.toString('utf8'); });
|
||||
child.stderr.on('data', d => { stderr += d.toString('utf8'); });
|
||||
child.on('error', e => {
|
||||
fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`));
|
||||
});
|
||||
child.on('close', code => {
|
||||
if (settled) return;
|
||||
if (code !== 0) {
|
||||
return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`));
|
||||
}
|
||||
succeed({ stdout, stderr });
|
||||
});
|
||||
|
||||
if (stdinText) {
|
||||
try {
|
||||
child.stdin.write(stdinText);
|
||||
child.stdin.end();
|
||||
} catch (e) {
|
||||
// swallow — child may have exited
|
||||
}
|
||||
} else {
|
||||
try { child.stdin.end(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function summarizeViaClaudeCode(system, user, settings, job) {
|
||||
const cmd = resolveCliPath('claude', settings.cliPath);
|
||||
// -p = print mode; disallow all tools so it returns plain text only; request JSON output format
|
||||
const args = [
|
||||
'-p',
|
||||
'--output-format', 'json',
|
||||
'--append-system-prompt', system,
|
||||
'--disallowed-tools', 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task',
|
||||
];
|
||||
if (settings.model) {
|
||||
args.push('--model', settings.model);
|
||||
}
|
||||
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job);
|
||||
|
||||
// claude -p --output-format json returns {"type":"result", ..., "result":"..."}
|
||||
let envelope;
|
||||
try { envelope = JSON.parse(stdout); } catch (e) {
|
||||
throw new Error('claude CLI returned a non-JSON envelope:\n' + stdout.slice(0, 500));
|
||||
}
|
||||
const resultText = envelope.result || envelope.content || '';
|
||||
return parseCardsJson(resultText, settings);
|
||||
}
|
||||
|
||||
async function summarizeViaCodex(system, user, settings, job) {
|
||||
const cmd = resolveCliPath('codex', settings.cliPath);
|
||||
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`;
|
||||
const args = ['exec', '--skip-git-repo-check', '-'];
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job);
|
||||
return parseCardsJson(stdout, settings);
|
||||
}
|
||||
|
||||
async function testBackend(settings) {
|
||||
if (settings.backend === 'codex') {
|
||||
const cmd = resolveCliPath('codex', settings.cliPath);
|
||||
|
|
@ -1781,6 +1650,7 @@ export const __test = {
|
|||
nextCardIndex,
|
||||
pruneCacheEntries,
|
||||
removeCardAt,
|
||||
resolveCliPath,
|
||||
serializeCacheFile,
|
||||
shouldConfirmRegenerate,
|
||||
summarizeViaApi,
|
||||
|
|
|
|||
138
src/cli.ts
Normal file
138
src/cli.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use strict';
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { GenerationJobCancelledError } from './generation-job-manager';
|
||||
import { parseCardsJson } from './schema';
|
||||
|
||||
/* Obsidian launched from GUI doesn't inherit shell PATH. */
|
||||
export function resolveCliPath(name, override) {
|
||||
if (override && override.trim()) return override.trim();
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, 'bin', name),
|
||||
path.join(home, '.local/bin', name),
|
||||
path.join(home, '.claude/local', name),
|
||||
path.join(home, '.codex/bin', name),
|
||||
path.join(home, '.bun/bin', name),
|
||||
path.join(home, '.npm-global/bin', name),
|
||||
path.join(home, '.cargo/bin', name),
|
||||
'/opt/homebrew/bin/' + name,
|
||||
'/usr/local/bin/' + name,
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch (_) {
|
||||
// Ignore unreadable candidate paths and keep searching.
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function runCli(cmd, args, stdinText, timeoutMs, job?) {
|
||||
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
let child;
|
||||
let settled = false;
|
||||
let timer;
|
||||
const fail = err => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
const succeed = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: [
|
||||
process.env.PATH || '',
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
path.join(os.homedir(), '.local/bin'),
|
||||
path.join(os.homedir(), '.claude/local'),
|
||||
].filter(Boolean).join(':'),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return reject(new Error(`Failed to start ${cmd}: ${e.message}`));
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
timer = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
||||
fail(new Error(`CLI timed out (${timeoutMs}ms)`));
|
||||
}, timeoutMs);
|
||||
if (job) {
|
||||
job.onCancel(() => {
|
||||
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
||||
fail(new GenerationJobCancelledError(job.key));
|
||||
});
|
||||
}
|
||||
|
||||
child.stdout.on('data', d => { stdout += d.toString('utf8'); });
|
||||
child.stderr.on('data', d => { stderr += d.toString('utf8'); });
|
||||
child.on('error', e => {
|
||||
fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`));
|
||||
});
|
||||
child.on('close', code => {
|
||||
if (settled) return;
|
||||
if (code !== 0) {
|
||||
return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`));
|
||||
}
|
||||
succeed({ stdout, stderr });
|
||||
});
|
||||
|
||||
if (stdinText) {
|
||||
try {
|
||||
child.stdin.write(stdinText);
|
||||
child.stdin.end();
|
||||
} catch (e) {
|
||||
// Child may have exited before stdin was written.
|
||||
}
|
||||
} else {
|
||||
try { child.stdin.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function summarizeViaClaudeCode(system, user, settings, job) {
|
||||
const cmd = resolveCliPath('claude', settings.cliPath);
|
||||
const args = [
|
||||
'-p',
|
||||
'--output-format', 'json',
|
||||
'--append-system-prompt', system,
|
||||
'--disallowed-tools', 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task',
|
||||
];
|
||||
if (settings.model) {
|
||||
args.push('--model', settings.model);
|
||||
}
|
||||
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job);
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(stdout);
|
||||
} catch (e) {
|
||||
throw new Error('claude CLI returned a non-JSON envelope:\n' + stdout.slice(0, 500));
|
||||
}
|
||||
const resultText = envelope.result || envelope.content || '';
|
||||
return parseCardsJson(resultText, settings);
|
||||
}
|
||||
|
||||
export async function summarizeViaCodex(system, user, settings, job) {
|
||||
const cmd = resolveCliPath('codex', settings.cliPath);
|
||||
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`;
|
||||
const args = ['exec', '--skip-git-repo-check', '-'];
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job);
|
||||
return parseCardsJson(stdout, settings);
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ assert.ok(/cacheTouch[\s\S]*scheduleCacheSave/.test(mainSource), 'cacheTouch sho
|
|||
assert.ok(!/cacheTouch[\s\S]{0,220}await this\.saveCache/.test(mainSource), 'cacheTouch should not synchronously write cache.json');
|
||||
assert.strictEqual(typeof t.cardsToMarkdown, 'function');
|
||||
assert.strictEqual(typeof t.cancellationNoticeKey, 'function');
|
||||
assert.strictEqual(typeof t.resolveCliPath, 'function');
|
||||
assert.strictEqual(typeof t.buildPrompts, 'function');
|
||||
assert.strictEqual(typeof t.buildOpenAiChatBody, 'function');
|
||||
assert.strictEqual(typeof t.extractJson, 'function');
|
||||
|
|
@ -134,6 +135,7 @@ assert.strictEqual(
|
|||
'cancelRequested',
|
||||
'API cancellation outside the request phase can use the generic notice'
|
||||
);
|
||||
assert.strictEqual(t.resolveCliPath('codex', ' /tmp/codex '), '/tmp/codex');
|
||||
assert.throws(
|
||||
() => t.modelForApi({ ...baseSettings, model: '', uiLanguage: 'en' }),
|
||||
/Model is not set/,
|
||||
|
|
|
|||
Loading…
Reference in a new issue