mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation Separates CLI adapters from the core runtime to prepare for independent adapter distribution via postinstall fetch. Key changes: - Move src/clis/ → clis/ (adapters at repo root) - Change tsconfig rootDir from "src" to "." so tsc compiles both - Create root-level shim files (registry.ts, errors.ts, etc.) so adapter relative imports (../../registry.js) resolve correctly - Update build-manifest.ts, main.ts paths for new dist/src/ structure - Expand ensureUserCliCompatShims() to cover all adapter import targets (types, utils, logger, launcher, browser/*, download/*, pipeline/*) - Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync - Update vitest.config.ts adapter test paths - Add package.json files field to exclude adapters from npm package Official adapter files are unconditionally overwritten on update; user-created files not in the manifest are preserved. * fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters - Add dist/clis/ and dist/cli-manifest.json to package.json files field so built-in adapters and manifest ship with the npm package - Replace execSync with execFileSync to prevent command injection - Add version check to skip redundant adapter fetches - Track tmpRoot explicitly for reliable cleanup * fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback 1. Manifest-based update strategy: - Read old manifest to identify previously-official files - Clean up files removed upstream (in old manifest but not new) - User-created files (never in any manifest) remain untouched 2. Only run fetch-adapters on global install (npm_config_global=true) or explicit OPENCLI_FETCH=1, preventing heavy side effects for local/dev installs 3. First-run fallback in discovery.ts: - ensureUserAdapters() checks for adapter-manifest.json - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js - Guarantees adapters are available even with --ignore-scripts * fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead * feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger * simplify: replace git clone with local copy from dist/clis/ Adapters already ship in the npm package (dist/clis/), so there's no need to clone from GitHub. Copy directly from the installed package: - Eliminates git, curl, tar dependencies - No network calls in postinstall - No timeout/offline issues - Version always matches the installed CLI - ~65 lines of clone/download code replaced by one cpSync loop
187 lines
7.4 KiB
TypeScript
187 lines
7.4 KiB
TypeScript
/**
|
|
* Reddit post reader with threaded comment tree.
|
|
*
|
|
* Replaces the original flat read.yaml with recursive comment traversal:
|
|
* - Top-K comments by score at each level
|
|
* - Configurable depth and replies-per-level
|
|
* - Indented output showing conversation threads
|
|
*/
|
|
import { cli, Strategy } from '../../registry.js';
|
|
import { CommandExecutionError } from '../../errors.js';
|
|
|
|
cli({
|
|
site: 'reddit',
|
|
name: 'read',
|
|
description: 'Read a Reddit post and its comments',
|
|
domain: 'reddit.com',
|
|
strategy: Strategy.COOKIE,
|
|
args: [
|
|
{ name: 'post-id', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or full URL' },
|
|
{ name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
|
|
{ name: 'limit', type: 'int', default: 25, help: 'Number of top-level comments' },
|
|
{ name: 'depth', type: 'int', default: 2, help: 'Max reply depth (1=no replies, 2=one level of replies, etc.)' },
|
|
{ name: 'replies', type: 'int', default: 5, help: 'Max replies shown per comment at each level (sorted by score)' },
|
|
{ name: 'max-length', type: 'int', default: 2000, help: 'Max characters per comment body (min 100)' },
|
|
],
|
|
columns: ['type', 'author', 'score', 'text'],
|
|
func: async (page, kwargs) => {
|
|
const sort = kwargs.sort ?? 'best';
|
|
const limit = Math.max(1, kwargs.limit ?? 25);
|
|
const maxDepth = Math.max(1, kwargs.depth ?? 2);
|
|
const maxReplies = Math.max(1, kwargs.replies ?? 5);
|
|
const maxLength = Math.max(100, kwargs['max-length'] ?? 2000);
|
|
|
|
await page.goto('https://www.reddit.com');
|
|
|
|
const data = await page.evaluate(`
|
|
(async function() {
|
|
var postId = ${JSON.stringify(kwargs['post-id'])};
|
|
var urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
|
if (urlMatch) postId = urlMatch[1];
|
|
|
|
var sort = ${JSON.stringify(sort)};
|
|
var limit = ${limit};
|
|
var maxDepth = ${maxDepth};
|
|
var maxReplies = ${maxReplies};
|
|
var maxLength = ${maxLength};
|
|
|
|
// Request more from API than top-level limit to get inline replies
|
|
// depth param tells Reddit how deep to inline replies vs "more" stubs
|
|
var apiLimit = Math.max(limit * 3, 100);
|
|
var res = await fetch(
|
|
'/comments/' + postId + '.json?sort=' + sort + '&limit=' + apiLimit + '&depth=' + (maxDepth + 1) + '&raw_json=1',
|
|
{ credentials: 'include' }
|
|
);
|
|
if (!res.ok) return { error: 'Reddit API returned HTTP ' + res.status };
|
|
|
|
var data;
|
|
try { data = await res.json(); } catch(e) { return { error: 'Failed to parse response' }; }
|
|
if (!Array.isArray(data) || data.length < 2) return { error: 'Unexpected response format' };
|
|
|
|
var results = [];
|
|
|
|
// Post
|
|
var post = data[0] && data[0].data && data[0].data.children && data[0].data.children[0] && data[0].data.children[0].data;
|
|
if (post) {
|
|
var body = post.selftext || '';
|
|
if (body.length > maxLength) body = body.slice(0, maxLength) + '\\n... [truncated]';
|
|
results.push({
|
|
type: 'POST',
|
|
author: post.author || '[deleted]',
|
|
score: post.score || 0,
|
|
text: post.title + (body ? '\\n\\n' + body : '') + (post.url && !post.is_self ? '\\n' + post.url : ''),
|
|
});
|
|
}
|
|
|
|
// Recursive comment walker
|
|
// depth 0 = top-level comments; maxDepth is exclusive,
|
|
// so --depth 1 means top-level only, --depth 2 means one reply level, etc.
|
|
function walkComment(node, depth) {
|
|
if (!node || node.kind !== 't1') return;
|
|
var d = node.data;
|
|
var body = d.body || '';
|
|
if (body.length > maxLength) body = body.slice(0, maxLength) + '...';
|
|
|
|
// Indent prefix: apply to every line so multiline bodies stay aligned
|
|
var indent = '';
|
|
for (var i = 0; i < depth; i++) indent += ' ';
|
|
var prefix = depth === 0 ? '' : indent + '> ';
|
|
var indentedBody = depth === 0
|
|
? body
|
|
: body.split('\\n').map(function(line) { return prefix + line; }).join('\\n');
|
|
|
|
results.push({
|
|
type: depth === 0 ? 'L0' : 'L' + depth,
|
|
author: d.author || '[deleted]',
|
|
score: d.score || 0,
|
|
text: indentedBody,
|
|
});
|
|
|
|
// Count all available replies (for accurate "more" count)
|
|
var t1Children = [];
|
|
var moreCount = 0;
|
|
if (d.replies && d.replies.data && d.replies.data.children) {
|
|
var children = d.replies.data.children;
|
|
for (var i = 0; i < children.length; i++) {
|
|
if (children[i].kind === 't1') {
|
|
t1Children.push(children[i]);
|
|
} else if (children[i].kind === 'more') {
|
|
moreCount += children[i].data.count || 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// At depth cutoff: don't recurse, but show all replies as hidden
|
|
if (depth + 1 >= maxDepth) {
|
|
var totalHidden = t1Children.length + moreCount;
|
|
if (totalHidden > 0) {
|
|
var cutoffIndent = '';
|
|
for (var j = 0; j <= depth; j++) cutoffIndent += ' ';
|
|
results.push({
|
|
type: 'L' + (depth + 1),
|
|
author: '',
|
|
score: '',
|
|
text: cutoffIndent + '[+' + totalHidden + ' more replies]',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Sort by score descending, take top N
|
|
t1Children.sort(function(a, b) { return (b.data.score || 0) - (a.data.score || 0); });
|
|
var toProcess = Math.min(t1Children.length, maxReplies);
|
|
for (var i = 0; i < toProcess; i++) {
|
|
walkComment(t1Children[i], depth + 1);
|
|
}
|
|
|
|
// Show hidden count (skipped replies + "more" stubs)
|
|
var hidden = t1Children.length - toProcess + moreCount;
|
|
if (hidden > 0) {
|
|
var moreIndent = '';
|
|
for (var j = 0; j <= depth; j++) moreIndent += ' ';
|
|
results.push({
|
|
type: 'L' + (depth + 1),
|
|
author: '',
|
|
score: '',
|
|
text: moreIndent + '[+' + hidden + ' more replies]',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Walk top-level comments
|
|
var topLevel = data[1].data.children || [];
|
|
var t1TopLevel = [];
|
|
for (var i = 0; i < topLevel.length; i++) {
|
|
if (topLevel[i].kind === 't1') t1TopLevel.push(topLevel[i]);
|
|
}
|
|
|
|
// Top-level are already sorted by Reddit (sort param), take top N
|
|
for (var i = 0; i < Math.min(t1TopLevel.length, limit); i++) {
|
|
walkComment(t1TopLevel[i], 0);
|
|
}
|
|
|
|
// Count remaining
|
|
var moreTopLevel = topLevel.filter(function(c) { return c.kind === 'more'; })
|
|
.reduce(function(sum, c) { return sum + (c.data.count || 0); }, 0);
|
|
var hiddenTopLevel = Math.max(0, t1TopLevel.length - limit) + moreTopLevel;
|
|
if (hiddenTopLevel > 0) {
|
|
results.push({
|
|
type: '',
|
|
author: '',
|
|
score: '',
|
|
text: '[+' + hiddenTopLevel + ' more top-level comments]',
|
|
});
|
|
}
|
|
|
|
return results;
|
|
})()
|
|
`);
|
|
|
|
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch post data');
|
|
if (!Array.isArray(data) && data.error) throw new CommandExecutionError(data.error);
|
|
if (!Array.isArray(data)) throw new CommandExecutionError('Unexpected response');
|
|
|
|
return data;
|
|
},
|
|
});
|