Files
jackwener__opencli/clis/reddit/read.ts
jakevin 80eef46b4e refactor: monorepo adapter separation (clis/ at root) (#782)
* 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
2026-04-05 01:46:36 +08:00

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;
},
});