tokwise 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/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/ask.js +58 -0
- package/dist/browser-cookies.js +160 -0
- package/dist/classify.js +118 -0
- package/dist/cli.js +894 -0
- package/dist/jsonl.js +51 -0
- package/dist/library.js +138 -0
- package/dist/markdown.js +211 -0
- package/dist/media.js +117 -0
- package/dist/paths.js +87 -0
- package/dist/process.js +68 -0
- package/dist/progress.js +56 -0
- package/dist/render.js +114 -0
- package/dist/search.js +226 -0
- package/dist/skill.js +57 -0
- package/dist/store.js +158 -0
- package/dist/tiktok.js +445 -0
- package/dist/transcribe.js +162 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { answerQuestion } from "./ask.js";
|
|
9
|
+
import { classifyOne } from "./classify.js";
|
|
10
|
+
import { clearAuth, findVideo, loadAuth, loadCookie, loadPreferences, loadVideos, mergeVideos, readTextInput, saveAuth, savePreferences, saveVideos } from "./store.js";
|
|
11
|
+
import { extractTikTokCookie, isChromiumBrowser, SUPPORTED_BROWSERS } from "./browser-cookies.js";
|
|
12
|
+
import { commandsDir, dataDir, ensureDataDirs, libraryDir, searchIndexPath, toDisplayPath, videosJsonlPath } from "./paths.js";
|
|
13
|
+
import { compileWiki, exportMarkdown, lintWiki } from "./markdown.js";
|
|
14
|
+
import { downloadMedia } from "./media.js";
|
|
15
|
+
import { formatSearchResults, loadSearchIndex, saveSearchIndex, searchWithIndex } from "./search.js";
|
|
16
|
+
import { detectLoggedInUsername, fetchCollection, fetchLiked, fetchPlaylist, fetchSingleUrl, fetchUserPosts, fetchVideoSearch, videosFromImport, videosFromUrls } from "./tiktok.js";
|
|
17
|
+
import { transcribeVideo } from "./transcribe.js";
|
|
18
|
+
import { createCommand, createLibraryPage, deleteLibraryPage, listCommands, searchLibrary, showLibraryPage, updateLibraryPage, validateCommands } from "./library.js";
|
|
19
|
+
import { installSkill, skillContent, uninstallSkill } from "./skill.js";
|
|
20
|
+
import { barChart, box, c, kvList, setColorEnabled, truncate } from "./render.js";
|
|
21
|
+
import { createProgress } from "./progress.js";
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
function version() {
|
|
24
|
+
try {
|
|
25
|
+
return require("../package.json").version ?? "0.0.0";
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "0.0.0";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function safe(fn) {
|
|
32
|
+
return async (...args) => {
|
|
33
|
+
try {
|
|
34
|
+
await fn(...args);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`Error: ${error.message}`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function collect(value, previous = []) {
|
|
43
|
+
previous.push(value);
|
|
44
|
+
return previous;
|
|
45
|
+
}
|
|
46
|
+
function boolFromString(value) {
|
|
47
|
+
if (value === "true")
|
|
48
|
+
return true;
|
|
49
|
+
if (value === "false")
|
|
50
|
+
return false;
|
|
51
|
+
throw new Error("Expected true or false.");
|
|
52
|
+
}
|
|
53
|
+
function engineOption() {
|
|
54
|
+
return new Option("--engine <engine>", "Analysis engine").choices(["regex", "ollama"]);
|
|
55
|
+
}
|
|
56
|
+
function resolveBrowserOption(value) {
|
|
57
|
+
if (value == null)
|
|
58
|
+
return undefined;
|
|
59
|
+
const name = String(value).toLowerCase();
|
|
60
|
+
if (!isChromiumBrowser(name)) {
|
|
61
|
+
throw new Error(`Unsupported browser "${value}". Choose one of: ${SUPPORTED_BROWSERS.join(", ")}.`);
|
|
62
|
+
}
|
|
63
|
+
return name;
|
|
64
|
+
}
|
|
65
|
+
export function buildCli() {
|
|
66
|
+
const program = new Command();
|
|
67
|
+
program
|
|
68
|
+
.name("tokwise")
|
|
69
|
+
.description("Local-first CLI for saved short-form videos, transcripts, search, and agent workflows.")
|
|
70
|
+
.version(version())
|
|
71
|
+
.option("--no-color", "Disable colored output")
|
|
72
|
+
.showHelpAfterError();
|
|
73
|
+
program.hook("preAction", (thisCommand, actionCommand) => {
|
|
74
|
+
if (thisCommand.opts().color === false)
|
|
75
|
+
setColorEnabled(false);
|
|
76
|
+
if (actionCommand.opts().json !== true)
|
|
77
|
+
console.log("");
|
|
78
|
+
});
|
|
79
|
+
program
|
|
80
|
+
.command("auth")
|
|
81
|
+
.description("Manage a local browser cookie for private sources")
|
|
82
|
+
.addCommand(new Command("set")
|
|
83
|
+
.description("Save a browser cookie locally")
|
|
84
|
+
.option("--cookie <cookie>", "Cookie string")
|
|
85
|
+
.option("--stdin", "Read cookie from stdin")
|
|
86
|
+
.option("--username <handle>", "TikTok @handle tied to this cookie")
|
|
87
|
+
.action(safe(async (options) => {
|
|
88
|
+
const cookie = options.stdin ? (await readTextInput("-")).trim() : options.cookie;
|
|
89
|
+
if (!cookie)
|
|
90
|
+
throw new Error("Pass --cookie or --stdin.");
|
|
91
|
+
ensureDataDirs();
|
|
92
|
+
const existing = await loadAuth();
|
|
93
|
+
const username = options.username ?? (await detectLoggedInUsername(cookie)) ?? existing.username;
|
|
94
|
+
await saveAuth({ ...existing, cookie, username, source: "manual", updatedAt: new Date().toISOString() });
|
|
95
|
+
console.log(username
|
|
96
|
+
? `Saved browser cookie locally (username: @${username}).`
|
|
97
|
+
: "Saved browser cookie locally. Run `tw auth set-username <handle>` to enable bare collection slugs.");
|
|
98
|
+
})))
|
|
99
|
+
.addCommand(new Command("from-browser")
|
|
100
|
+
.description("Extract the TikTok cookie from a logged-in macOS Chromium browser")
|
|
101
|
+
.option("--browser <name>", `Browser to read from (${SUPPORTED_BROWSERS.join(", ")}); auto-detected if omitted`)
|
|
102
|
+
.option("--profile <name>", "Browser profile directory", "Default")
|
|
103
|
+
.option("--username <handle>", "TikTok @handle tied to this cookie")
|
|
104
|
+
.option("--print", "Print the cookie header instead of saving it")
|
|
105
|
+
.action(safe(async (options) => {
|
|
106
|
+
const browser = resolveBrowserOption(options.browser);
|
|
107
|
+
const profile = String(options.profile);
|
|
108
|
+
const extracted = await extractTikTokCookie({ browser, profile });
|
|
109
|
+
if (!extracted.cookie.includes("sessionid=")) {
|
|
110
|
+
console.warn("Warning: extracted cookie has no sessionid; the session may be incomplete or logged out.");
|
|
111
|
+
}
|
|
112
|
+
if (options.print) {
|
|
113
|
+
console.log(extracted.cookie);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
ensureDataDirs();
|
|
117
|
+
const existing = await loadAuth();
|
|
118
|
+
const username = options.username ?? (await detectLoggedInUsername(extracted.cookie)) ?? existing.username;
|
|
119
|
+
await saveAuth({
|
|
120
|
+
...existing,
|
|
121
|
+
cookie: extracted.cookie,
|
|
122
|
+
username,
|
|
123
|
+
source: "browser",
|
|
124
|
+
browser: extracted.browser,
|
|
125
|
+
profile: extracted.profile,
|
|
126
|
+
updatedAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
console.log(`Saved cookie from ${extracted.browser} (profile "${extracted.profile}")${username ? ` for @${username}` : ""}.`);
|
|
129
|
+
})))
|
|
130
|
+
.addCommand(new Command("refresh")
|
|
131
|
+
.description("Re-extract the cookie using the browser and profile saved previously")
|
|
132
|
+
.action(safe(async () => {
|
|
133
|
+
const auth = await loadAuth();
|
|
134
|
+
if (auth.source !== "browser" || !auth.browser) {
|
|
135
|
+
throw new Error("No browser-extracted cookie to refresh. Run `tw auth from-browser` first.");
|
|
136
|
+
}
|
|
137
|
+
const browser = resolveBrowserOption(auth.browser);
|
|
138
|
+
const profile = auth.profile ?? "Default";
|
|
139
|
+
const extracted = await extractTikTokCookie({ browser, profile });
|
|
140
|
+
if (!extracted.cookie.includes("sessionid=")) {
|
|
141
|
+
console.warn("Warning: extracted cookie has no sessionid; the session may be incomplete or logged out.");
|
|
142
|
+
}
|
|
143
|
+
ensureDataDirs();
|
|
144
|
+
const username = (await detectLoggedInUsername(extracted.cookie)) ?? auth.username;
|
|
145
|
+
await saveAuth({
|
|
146
|
+
...auth,
|
|
147
|
+
cookie: extracted.cookie,
|
|
148
|
+
username,
|
|
149
|
+
source: "browser",
|
|
150
|
+
browser: extracted.browser,
|
|
151
|
+
profile: extracted.profile,
|
|
152
|
+
updatedAt: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
console.log(`Refreshed cookie from ${extracted.browser} (profile "${extracted.profile}")${username ? ` for @${username}` : ""}.`);
|
|
155
|
+
})))
|
|
156
|
+
.addCommand(new Command("show").description("Show whether a cookie is saved").action(safe(async () => {
|
|
157
|
+
const auth = await loadAuth();
|
|
158
|
+
if (!auth.cookie) {
|
|
159
|
+
console.log("No cookie saved.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const source = auth.source === "browser"
|
|
163
|
+
? `browser (${auth.browser ?? "unknown"}/${auth.profile ?? "Default"})`
|
|
164
|
+
: "manual";
|
|
165
|
+
const username = auth.username ? `, username: @${auth.username}` : "";
|
|
166
|
+
console.log(`Cookie saved (${auth.cookie.length} chars, source: ${source}${username}).`);
|
|
167
|
+
})))
|
|
168
|
+
.addCommand(new Command("set-username")
|
|
169
|
+
.description("Save the TikTok @handle tied to your cookie (enables bare collection slugs)")
|
|
170
|
+
.argument("<handle>", "TikTok username, with or without a leading @")
|
|
171
|
+
.action(safe(async (handle) => {
|
|
172
|
+
const username = handle.trim().replace(/^@/, "");
|
|
173
|
+
if (!username)
|
|
174
|
+
throw new Error("Pass a TikTok handle.");
|
|
175
|
+
ensureDataDirs();
|
|
176
|
+
const existing = await loadAuth();
|
|
177
|
+
await saveAuth({ ...existing, username, updatedAt: new Date().toISOString() });
|
|
178
|
+
console.log(`Saved username @${username}.`);
|
|
179
|
+
})))
|
|
180
|
+
.addCommand(new Command("clear").description("Remove saved browser cookie").action(safe(async () => {
|
|
181
|
+
await clearAuth();
|
|
182
|
+
console.log("Removed saved browser cookie.");
|
|
183
|
+
})));
|
|
184
|
+
program
|
|
185
|
+
.command("sync")
|
|
186
|
+
.description("Sync short-form video sources into the local archive")
|
|
187
|
+
.option("--collection <idOrUrl>", "Collection URL, @user/collection/slug, or bare slug/id", collect, [])
|
|
188
|
+
.option("--playlist <idOrUrl>", "Playlist id or URL", collect, [])
|
|
189
|
+
.option("--liked <username>", "Sync a user's liked videos; usually requires cookie", collect, [])
|
|
190
|
+
.option("--user <username>", "Sync a user's posts", collect, [])
|
|
191
|
+
.option("--search-video <query>", "Sync video search results", collect, [])
|
|
192
|
+
.option("--url <url>", "Sync one source URL", collect, [])
|
|
193
|
+
.option("--urls-file <path>", "Newline-delimited source URLs")
|
|
194
|
+
.option("--input <path>", "Import JSON, JSONL, or raw API response")
|
|
195
|
+
.option("--cookie <cookie>", "Browser cookie for API calls")
|
|
196
|
+
.option("--cookie-file <path>", "Read browser cookie from file")
|
|
197
|
+
.option("--proxy <url>", "Proxy passed to source API and yt-dlp")
|
|
198
|
+
.option("--limit <n>", "Max items per source", parseNumber, 30)
|
|
199
|
+
.option("--page <n>", "Start page", parseNumber, 1)
|
|
200
|
+
.option("--pages <n>", "Max pages per paged source", parseNumber)
|
|
201
|
+
.option("--rebuild", "Replace archive with this sync result", false)
|
|
202
|
+
.option("--download", "Download media after syncing", false)
|
|
203
|
+
.option("--audio", "When downloading, extract audio only", false)
|
|
204
|
+
.option("--transcribe", "Transcribe after downloading or when audio/video already exists", false)
|
|
205
|
+
.option("--classify", "Classify synced records after sync/transcription", false)
|
|
206
|
+
.option("--yt-dlp <command>", "yt-dlp command path", "yt-dlp")
|
|
207
|
+
.option("--yt-dlp-cookies <path>", "Netscape cookies file for yt-dlp")
|
|
208
|
+
.option("--cookies-from-browser <browser>", "Forward to yt-dlp --cookies-from-browser")
|
|
209
|
+
.option("--stt-engine <engine>", "whisper, whisper-cpp, or custom", "whisper")
|
|
210
|
+
.option("--stt-command <command>", "STT command path or template")
|
|
211
|
+
.option("--stt-model <model>", "STT model name/path")
|
|
212
|
+
.option("--language <code>", "STT language code")
|
|
213
|
+
.addOption(engineOption().default("regex"))
|
|
214
|
+
.option("--model <model>", "Local model for Ollama classification")
|
|
215
|
+
.option("--ollama-url <url>", "Ollama base URL", "http://localhost:11434")
|
|
216
|
+
.action(safe(async (options) => {
|
|
217
|
+
ensureDataDirs();
|
|
218
|
+
const cookie = await loadCookie({ cookie: options.cookie, cookieFile: options.cookieFile });
|
|
219
|
+
const auth = await loadAuth();
|
|
220
|
+
const discovered = [];
|
|
221
|
+
const fetchOptions = {
|
|
222
|
+
cookie,
|
|
223
|
+
username: auth.username,
|
|
224
|
+
proxy: options.proxy,
|
|
225
|
+
limit: Number(options.limit),
|
|
226
|
+
page: Number(options.page),
|
|
227
|
+
pages: options.pages == null ? undefined : Number(options.pages),
|
|
228
|
+
};
|
|
229
|
+
for (const value of options.collection)
|
|
230
|
+
discovered.push(...(await fetchCollection(value, fetchOptions)));
|
|
231
|
+
for (const value of options.playlist)
|
|
232
|
+
discovered.push(...(await fetchPlaylist(value, fetchOptions)));
|
|
233
|
+
for (const value of options.liked)
|
|
234
|
+
discovered.push(...(await fetchLiked(value, fetchOptions)));
|
|
235
|
+
for (const value of options.user)
|
|
236
|
+
discovered.push(...(await fetchUserPosts(value, fetchOptions)));
|
|
237
|
+
for (const value of options.searchVideo)
|
|
238
|
+
discovered.push(...(await fetchVideoSearch(value, fetchOptions)));
|
|
239
|
+
for (const value of options.url)
|
|
240
|
+
discovered.push(await fetchSingleUrl(value, fetchOptions));
|
|
241
|
+
if (options.urlsFile) {
|
|
242
|
+
const urls = (await fs.readFile(String(options.urlsFile), "utf8"))
|
|
243
|
+
.split(/\r?\n/)
|
|
244
|
+
.map((line) => line.trim())
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
discovered.push(...videosFromUrls(urls));
|
|
247
|
+
}
|
|
248
|
+
if (options.input) {
|
|
249
|
+
discovered.push(...(await readImport(String(options.input))));
|
|
250
|
+
}
|
|
251
|
+
if (discovered.length === 0)
|
|
252
|
+
throw new Error("No source supplied. Try --collection, --url, --urls-file, or --input.");
|
|
253
|
+
const sync = await mergeVideos(discovered, { rebuild: Boolean(options.rebuild) });
|
|
254
|
+
console.log(`Synced ${sync.added} new, ${sync.updated} updated, ${sync.unchanged} unchanged (${sync.total} total).`);
|
|
255
|
+
let videos = await loadVideos();
|
|
256
|
+
const touched = new Set(sync.ids);
|
|
257
|
+
if (options.download) {
|
|
258
|
+
videos = await runDownloads(videos, touched, {
|
|
259
|
+
ytDlp: options.ytDlp,
|
|
260
|
+
proxy: options.proxy,
|
|
261
|
+
cookiesFile: options.ytDlpCookies,
|
|
262
|
+
cookiesFromBrowser: options.cookiesFromBrowser,
|
|
263
|
+
audioOnly: Boolean(options.audio),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (options.transcribe) {
|
|
267
|
+
videos = await runTranscription(videos, touched, {
|
|
268
|
+
engine: options.sttEngine,
|
|
269
|
+
command: options.sttCommand,
|
|
270
|
+
model: options.sttModel,
|
|
271
|
+
language: options.language,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (options.classify) {
|
|
275
|
+
videos = await runClassification(videos, touched, {
|
|
276
|
+
engine: options.engine,
|
|
277
|
+
model: options.model,
|
|
278
|
+
ollamaBaseUrl: options.ollamaUrl,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const index = await saveSearchIndex(videos);
|
|
282
|
+
console.log(`Indexed ${index.recordCount} videos at ${toDisplayPath(searchIndexPath())}.`);
|
|
283
|
+
}));
|
|
284
|
+
program
|
|
285
|
+
.command("fetch-media")
|
|
286
|
+
.description("Download media for existing records with yt-dlp")
|
|
287
|
+
.option("--audio", "Extract audio only", false)
|
|
288
|
+
.option("--video", "Download video instead of audio", false)
|
|
289
|
+
.option("--force", "Redownload existing media", false)
|
|
290
|
+
.option("--limit <n>", "Max records", parseNumber)
|
|
291
|
+
.option("--query <query>", "Only records matching a search query")
|
|
292
|
+
.option("--yt-dlp <command>", "yt-dlp command path", "yt-dlp")
|
|
293
|
+
.option("--yt-dlp-cookies <path>", "Netscape cookies file for yt-dlp")
|
|
294
|
+
.option("--cookies-from-browser <browser>", "Forward to yt-dlp --cookies-from-browser")
|
|
295
|
+
.option("--proxy <url>", "Proxy URL")
|
|
296
|
+
.action(safe(async (options) => {
|
|
297
|
+
const videos = await selectVideos(await loadVideos(), { query: options.query, limit: options.limit });
|
|
298
|
+
const touched = new Set(videos.map((video) => video.id));
|
|
299
|
+
const next = await runDownloads(await loadVideos(), touched, {
|
|
300
|
+
ytDlp: options.ytDlp,
|
|
301
|
+
proxy: options.proxy,
|
|
302
|
+
cookiesFile: options.ytDlpCookies,
|
|
303
|
+
cookiesFromBrowser: options.cookiesFromBrowser,
|
|
304
|
+
audioOnly: !options.video,
|
|
305
|
+
force: options.force,
|
|
306
|
+
});
|
|
307
|
+
await saveSearchIndex(next);
|
|
308
|
+
}));
|
|
309
|
+
program
|
|
310
|
+
.command("transcribe")
|
|
311
|
+
.description("Transcribe downloaded audio/video")
|
|
312
|
+
.option("--engine <engine>", "whisper, whisper-cpp, or custom", "whisper")
|
|
313
|
+
.option("--command <command>", "STT command path or custom template")
|
|
314
|
+
.option("--model <model>", "Model name/path")
|
|
315
|
+
.option("--language <code>", "Language code")
|
|
316
|
+
.option("--force", "Retranscribe existing transcripts", false)
|
|
317
|
+
.option("--limit <n>", "Max records", parseNumber)
|
|
318
|
+
.option("--query <query>", "Only records matching a search query")
|
|
319
|
+
.action(safe(async (options) => {
|
|
320
|
+
const videos = await selectVideos(await loadVideos(), { query: options.query, limit: options.limit });
|
|
321
|
+
const touched = new Set(videos.map((video) => video.id));
|
|
322
|
+
const next = await runTranscription(await loadVideos(), touched, {
|
|
323
|
+
engine: options.engine,
|
|
324
|
+
command: options.command,
|
|
325
|
+
model: options.model,
|
|
326
|
+
language: options.language,
|
|
327
|
+
force: options.force,
|
|
328
|
+
});
|
|
329
|
+
await saveSearchIndex(next);
|
|
330
|
+
}));
|
|
331
|
+
program
|
|
332
|
+
.command("index")
|
|
333
|
+
.description("Rebuild search index")
|
|
334
|
+
.action(safe(async () => {
|
|
335
|
+
const index = await saveSearchIndex(await loadVideos());
|
|
336
|
+
console.log(`Indexed ${index.recordCount} videos.`);
|
|
337
|
+
}));
|
|
338
|
+
program
|
|
339
|
+
.command("search")
|
|
340
|
+
.description("Full-text search across clip metadata and transcripts")
|
|
341
|
+
.argument("<query>", "Search query")
|
|
342
|
+
.option("--author <handle>", "Filter by author")
|
|
343
|
+
.option("--after <date>", "Created after YYYY-MM-DD")
|
|
344
|
+
.option("--before <date>", "Created before YYYY-MM-DD")
|
|
345
|
+
.option("--category <name>", "Filter by category")
|
|
346
|
+
.option("--domain <name>", "Filter by domain")
|
|
347
|
+
.option("--collection <name>", "Filter by collection/source")
|
|
348
|
+
.option("--has-transcript <true|false>", "Filter transcript presence", boolFromString)
|
|
349
|
+
.option("--limit <n>", "Max results", parseNumber, 20)
|
|
350
|
+
.option("--json", "JSON output", false)
|
|
351
|
+
.action(safe(async (query, options) => {
|
|
352
|
+
if (options.json)
|
|
353
|
+
setColorEnabled(false);
|
|
354
|
+
const { videos, index } = await requireIndex();
|
|
355
|
+
const results = searchWithIndex(videos, index, { ...filtersFromOptions(options), query });
|
|
356
|
+
console.log(formatSearchResults(results, { json: options.json }));
|
|
357
|
+
}));
|
|
358
|
+
program
|
|
359
|
+
.command("list")
|
|
360
|
+
.description("List videos with filters")
|
|
361
|
+
.option("--query <query>", "Search query")
|
|
362
|
+
.option("--author <handle>", "Filter by author")
|
|
363
|
+
.option("--after <date>", "Created after YYYY-MM-DD")
|
|
364
|
+
.option("--before <date>", "Created before YYYY-MM-DD")
|
|
365
|
+
.option("--category <name>", "Filter by category")
|
|
366
|
+
.option("--domain <name>", "Filter by domain")
|
|
367
|
+
.option("--collection <name>", "Filter by collection/source")
|
|
368
|
+
.option("--source <source>", "collection, playlist, liked, user, search, url, import")
|
|
369
|
+
.option("--has-transcript <true|false>", "Filter transcript presence", boolFromString)
|
|
370
|
+
.option("--limit <n>", "Max results", parseNumber, 30)
|
|
371
|
+
.option("--offset <n>", "Offset", parseNumber, 0)
|
|
372
|
+
.option("--json", "JSON output", false)
|
|
373
|
+
.action(safe(async (options) => {
|
|
374
|
+
if (options.json)
|
|
375
|
+
setColorEnabled(false);
|
|
376
|
+
const { videos, index } = await requireIndex();
|
|
377
|
+
const results = searchWithIndex(videos, index, filtersFromOptions(options));
|
|
378
|
+
if (options.json)
|
|
379
|
+
console.log(JSON.stringify(results.map((result) => result.video), null, 2));
|
|
380
|
+
else
|
|
381
|
+
console.log(formatList(results.map((result) => result.video)));
|
|
382
|
+
}));
|
|
383
|
+
program
|
|
384
|
+
.command("show")
|
|
385
|
+
.description("Show one video")
|
|
386
|
+
.argument("<idOrUrl>", "Video id, prefix, or URL")
|
|
387
|
+
.option("--json", "JSON output", false)
|
|
388
|
+
.action(safe(async (idOrUrl, options) => {
|
|
389
|
+
if (options.json)
|
|
390
|
+
setColorEnabled(false);
|
|
391
|
+
const video = findVideo(await loadVideos(), idOrUrl);
|
|
392
|
+
if (!video)
|
|
393
|
+
throw new Error(`No video found for ${idOrUrl}.`);
|
|
394
|
+
console.log(options.json ? JSON.stringify(video, null, 2) : formatVideo(video));
|
|
395
|
+
}));
|
|
396
|
+
program
|
|
397
|
+
.command("similar")
|
|
398
|
+
.description("Find videos similar to one saved video")
|
|
399
|
+
.argument("<idOrUrl>", "Video id, prefix, or URL")
|
|
400
|
+
.option("--limit <n>", "Max results", parseNumber, 10)
|
|
401
|
+
.action(safe(async (idOrUrl, options) => {
|
|
402
|
+
const { videos, index } = await requireIndex();
|
|
403
|
+
const video = findVideo(videos, idOrUrl);
|
|
404
|
+
if (!video)
|
|
405
|
+
throw new Error(`No video found for ${idOrUrl}.`);
|
|
406
|
+
const query = [video.description, video.classification?.summary, video.transcript?.text?.slice(0, 1000), ...(video.classification?.topics ?? [])]
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.join(" ");
|
|
409
|
+
const results = searchWithIndex(videos, index, { query, limit: Number(options.limit) + 1 }).filter((result) => result.video.id !== video.id);
|
|
410
|
+
console.log(formatSearchResults(results.slice(0, Number(options.limit))));
|
|
411
|
+
}));
|
|
412
|
+
program
|
|
413
|
+
.command("sample")
|
|
414
|
+
.description("Show a random sample from a category")
|
|
415
|
+
.argument("<category>", "Category")
|
|
416
|
+
.option("--limit <n>", "Number to sample", parseNumber, 5)
|
|
417
|
+
.action(safe(async (category, options) => {
|
|
418
|
+
const videos = (await loadVideos()).filter((video) => video.classification?.category === category);
|
|
419
|
+
const shuffled = [...videos].sort(() => Math.random() - 0.5).slice(0, Number(options.limit));
|
|
420
|
+
console.log(formatList(shuffled));
|
|
421
|
+
}));
|
|
422
|
+
program.command("stats").description("Show archive stats").action(safe(async () => console.log(formatStats(await loadVideos()))));
|
|
423
|
+
program.command("viz").description("Show a terminal dashboard").action(safe(async () => console.log(formatViz(await loadVideos()))));
|
|
424
|
+
program.command("categories").description("Show category distribution").action(safe(async () => console.log(formatCounts(await loadVideos(), (video) => video.classification?.category ?? "uncategorized"))));
|
|
425
|
+
program.command("domains").description("Show domain distribution").action(safe(async () => console.log(formatCounts(await loadVideos(), (video) => video.classification?.domain ?? "general"))));
|
|
426
|
+
program.command("collections").description("Show collection/source distribution").action(safe(async () => console.log(formatCounts(await loadVideos(), (video) => video.collection?.name ?? video.collection?.id ?? video.source))));
|
|
427
|
+
program
|
|
428
|
+
.command("classify")
|
|
429
|
+
.description("Classify videos by category/domain/topics")
|
|
430
|
+
.addOption(engineOption().default("regex"))
|
|
431
|
+
.option("--regex", "Use regex rules", false)
|
|
432
|
+
.option("--all", "Reclassify already-classified records", false)
|
|
433
|
+
.option("--limit <n>", "Max records", parseNumber)
|
|
434
|
+
.option("--model <model>", "Ollama model")
|
|
435
|
+
.option("--ollama-url <url>", "Ollama base URL")
|
|
436
|
+
.action(safe(async (options) => {
|
|
437
|
+
const engine = options.regex ? "regex" : options.engine;
|
|
438
|
+
const videos = await loadVideos();
|
|
439
|
+
const eligible = videos
|
|
440
|
+
.filter((video) => options.all || !video.classification?.category)
|
|
441
|
+
.slice(0, options.limit == null ? undefined : Number(options.limit));
|
|
442
|
+
const touched = new Set(eligible.map((video) => video.id));
|
|
443
|
+
const next = await runClassification(videos, touched, {
|
|
444
|
+
engine,
|
|
445
|
+
model: options.model,
|
|
446
|
+
ollamaBaseUrl: options.ollamaUrl,
|
|
447
|
+
});
|
|
448
|
+
await saveSearchIndex(next);
|
|
449
|
+
}));
|
|
450
|
+
program
|
|
451
|
+
.command("model")
|
|
452
|
+
.description("View or change local model preferences")
|
|
453
|
+
.option("--classify-engine <engine>", "regex or ollama")
|
|
454
|
+
.option("--ask-engine <engine>", "extractive or ollama")
|
|
455
|
+
.option("--model <model>", "Default local model")
|
|
456
|
+
.option("--ollama-url <url>", "Ollama base URL")
|
|
457
|
+
.action(safe(async (options) => {
|
|
458
|
+
const prefs = await loadPreferences();
|
|
459
|
+
const next = {
|
|
460
|
+
...prefs,
|
|
461
|
+
classifyEngine: options.classifyEngine ?? prefs.classifyEngine,
|
|
462
|
+
askEngine: options.askEngine ?? prefs.askEngine,
|
|
463
|
+
model: options.model ?? prefs.model,
|
|
464
|
+
ollamaBaseUrl: options.ollamaUrl ?? prefs.ollamaBaseUrl,
|
|
465
|
+
};
|
|
466
|
+
if (JSON.stringify(next) !== JSON.stringify(prefs))
|
|
467
|
+
await savePreferences(next);
|
|
468
|
+
console.log(JSON.stringify(next, null, 2));
|
|
469
|
+
}));
|
|
470
|
+
program
|
|
471
|
+
.command("md")
|
|
472
|
+
.description("Export videos as Markdown pages")
|
|
473
|
+
.option("--changed", "Skip unchanged files", false)
|
|
474
|
+
.action(safe(async (options) => {
|
|
475
|
+
const result = await exportMarkdown(await loadVideos(), { changedOnly: options.changed });
|
|
476
|
+
console.log(`Markdown export: ${result.written} written, ${result.skipped} skipped.`);
|
|
477
|
+
}));
|
|
478
|
+
program
|
|
479
|
+
.command("wiki")
|
|
480
|
+
.description("Compile an interlinked local wiki")
|
|
481
|
+
.action(safe(async () => {
|
|
482
|
+
const result = await compileWiki(await loadVideos());
|
|
483
|
+
console.log(`Wiki written: ${result.written} files under ${toDisplayPath(libraryDir())}.`);
|
|
484
|
+
}));
|
|
485
|
+
program
|
|
486
|
+
.command("ask")
|
|
487
|
+
.description("Ask a question against local clip transcripts")
|
|
488
|
+
.argument("<question>", "Question")
|
|
489
|
+
.option("--engine <engine>", "extractive or ollama")
|
|
490
|
+
.option("--model <model>", "Ollama model")
|
|
491
|
+
.option("--ollama-url <url>", "Ollama base URL")
|
|
492
|
+
.option("--limit <n>", "Evidence count", parseNumber, 8)
|
|
493
|
+
.option("--save", "Save answer as a library page", false)
|
|
494
|
+
.action(safe(async (question, options) => {
|
|
495
|
+
const prefs = await loadPreferences();
|
|
496
|
+
const { videos, index } = await requireIndex();
|
|
497
|
+
const results = searchWithIndex(videos, index, { query: question, limit: Number(options.limit) });
|
|
498
|
+
const answer = await answerQuestion(question, results, {
|
|
499
|
+
engine: options.engine ?? prefs.askEngine ?? "extractive",
|
|
500
|
+
model: options.model ?? prefs.model,
|
|
501
|
+
ollamaBaseUrl: options.ollamaUrl ?? prefs.ollamaBaseUrl,
|
|
502
|
+
});
|
|
503
|
+
console.log(answer);
|
|
504
|
+
if (options.save) {
|
|
505
|
+
const file = path.join(libraryDir(), "answers", `${Date.now()}-${slug(question)}.md`);
|
|
506
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
507
|
+
await fs.writeFile(file, `# ${question}\n\n${answer}\n`, "utf8");
|
|
508
|
+
console.log(`Saved: ${toDisplayPath(file)}`);
|
|
509
|
+
}
|
|
510
|
+
}));
|
|
511
|
+
program
|
|
512
|
+
.command("lint")
|
|
513
|
+
.description("Check generated wiki links")
|
|
514
|
+
.option("--fix", "Create placeholder pages for missing links", false)
|
|
515
|
+
.action(safe(async (options) => {
|
|
516
|
+
const result = await lintWiki({ fix: options.fix });
|
|
517
|
+
if (result.broken.length === 0)
|
|
518
|
+
console.log("No broken wiki links.");
|
|
519
|
+
else {
|
|
520
|
+
console.log(result.broken.join("\n"));
|
|
521
|
+
if (result.fixed)
|
|
522
|
+
console.log(`Fixed ${result.fixed} missing pages.`);
|
|
523
|
+
}
|
|
524
|
+
}));
|
|
525
|
+
addLibraryCommands(program);
|
|
526
|
+
addPortableCommandCommands(program);
|
|
527
|
+
addSkillCommands(program);
|
|
528
|
+
program.command("paths").description("Show data paths").option("--json", "JSON output", false).action(safe(async (options) => {
|
|
529
|
+
if (options.json)
|
|
530
|
+
setColorEnabled(false);
|
|
531
|
+
const paths = { dataDir: dataDir(), videos: videosJsonlPath(), index: searchIndexPath(), library: libraryDir(), commands: commandsDir() };
|
|
532
|
+
console.log(options.json ? JSON.stringify(paths, null, 2) : kvList(Object.entries(paths).map(([key, value]) => [key, c.muted(toDisplayPath(value) ?? "")])));
|
|
533
|
+
}));
|
|
534
|
+
program.command("path").description("Print data directory").action(() => console.log(dataDir()));
|
|
535
|
+
program.command("status").description("Show archive status").option("--json", "JSON output", false).action(safe(async (options) => {
|
|
536
|
+
if (options.json)
|
|
537
|
+
setColorEnabled(false);
|
|
538
|
+
const videos = await loadVideos();
|
|
539
|
+
const status = {
|
|
540
|
+
videos: videos.length,
|
|
541
|
+
transcripts: videos.filter((video) => video.transcript?.text).length,
|
|
542
|
+
classified: videos.filter((video) => video.classification?.category).length,
|
|
543
|
+
dataDir: dataDir(),
|
|
544
|
+
libraryDir: libraryDir(),
|
|
545
|
+
indexExists: await fileExists(searchIndexPath()),
|
|
546
|
+
};
|
|
547
|
+
console.log(options.json ? JSON.stringify(status, null, 2) : formatStatus(status));
|
|
548
|
+
}));
|
|
549
|
+
return program;
|
|
550
|
+
}
|
|
551
|
+
async function runDownloads(videos, touched, options) {
|
|
552
|
+
const next = [...videos];
|
|
553
|
+
const total = next.filter((video) => touched.has(video.id)).length;
|
|
554
|
+
const progress = createProgress({ total, label: "media" });
|
|
555
|
+
let downloaded = 0;
|
|
556
|
+
let present = 0;
|
|
557
|
+
const failed = [];
|
|
558
|
+
for (const [idx, video] of next.entries()) {
|
|
559
|
+
if (!touched.has(video.id))
|
|
560
|
+
continue;
|
|
561
|
+
try {
|
|
562
|
+
const outcome = await downloadMedia(video, options);
|
|
563
|
+
next[idx] = { ...video, media: outcome.media };
|
|
564
|
+
if (outcome.changed)
|
|
565
|
+
downloaded += 1;
|
|
566
|
+
else
|
|
567
|
+
present += 1;
|
|
568
|
+
progress.tick(`${video.id} (${outcome.changed ? "downloaded" : "already present"})`);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
failed.push(video.id);
|
|
572
|
+
progress.fail(`${video.id} (failed: ${error.message})`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
progress.done();
|
|
576
|
+
await saveVideos(next);
|
|
577
|
+
console.log(`Media: ${downloaded} downloaded, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
|
|
578
|
+
return next;
|
|
579
|
+
}
|
|
580
|
+
async function runTranscription(videos, touched, options) {
|
|
581
|
+
const next = [...videos];
|
|
582
|
+
const total = next.filter((video) => touched.has(video.id)).length;
|
|
583
|
+
const progress = createProgress({ total, label: "transcribe" });
|
|
584
|
+
let transcribed = 0;
|
|
585
|
+
let present = 0;
|
|
586
|
+
const failed = [];
|
|
587
|
+
for (const [idx, video] of next.entries()) {
|
|
588
|
+
if (!touched.has(video.id))
|
|
589
|
+
continue;
|
|
590
|
+
try {
|
|
591
|
+
const outcome = await transcribeVideo(video, options);
|
|
592
|
+
if (outcome.transcript)
|
|
593
|
+
next[idx] = { ...video, transcript: outcome.transcript };
|
|
594
|
+
if (outcome.changed)
|
|
595
|
+
transcribed += 1;
|
|
596
|
+
else
|
|
597
|
+
present += 1;
|
|
598
|
+
progress.tick(`${video.id} (${outcome.changed ? "transcribed" : "already present"})`);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
failed.push(video.id);
|
|
602
|
+
progress.fail(`${video.id} (failed: ${error.message})`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
progress.done();
|
|
606
|
+
await saveVideos(next);
|
|
607
|
+
console.log(`Transcripts: ${transcribed} transcribed, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
|
|
608
|
+
return next;
|
|
609
|
+
}
|
|
610
|
+
async function runClassification(videos, touched, options) {
|
|
611
|
+
const next = [...videos];
|
|
612
|
+
const total = next.filter((video) => touched.has(video.id)).length;
|
|
613
|
+
const progress = createProgress({ total, label: "classify" });
|
|
614
|
+
let classified = 0;
|
|
615
|
+
const failed = [];
|
|
616
|
+
for (const [idx, video] of next.entries()) {
|
|
617
|
+
if (!touched.has(video.id))
|
|
618
|
+
continue;
|
|
619
|
+
try {
|
|
620
|
+
const classification = await classifyOne(video, options);
|
|
621
|
+
next[idx] = { ...video, classification };
|
|
622
|
+
classified += 1;
|
|
623
|
+
progress.tick(video.id);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
failed.push(video.id);
|
|
627
|
+
progress.fail(`${video.id} (failed: ${error.message})`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
progress.done();
|
|
631
|
+
await saveVideos(next);
|
|
632
|
+
console.log(`Classified ${classified} videos${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
|
|
633
|
+
return next;
|
|
634
|
+
}
|
|
635
|
+
async function readImport(filePath) {
|
|
636
|
+
const text = await readTextInput(filePath);
|
|
637
|
+
const trimmed = text.trim();
|
|
638
|
+
if (!trimmed)
|
|
639
|
+
return [];
|
|
640
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{"))
|
|
641
|
+
return videosFromImport(JSON.parse(trimmed));
|
|
642
|
+
return trimmed
|
|
643
|
+
.split(/\r?\n/)
|
|
644
|
+
.filter(Boolean)
|
|
645
|
+
.flatMap((line) => videosFromImport(JSON.parse(line)));
|
|
646
|
+
}
|
|
647
|
+
async function requireIndex() {
|
|
648
|
+
const videos = await loadVideos();
|
|
649
|
+
let index = await loadSearchIndex();
|
|
650
|
+
if (!index)
|
|
651
|
+
index = await saveSearchIndex(videos);
|
|
652
|
+
return { videos, index };
|
|
653
|
+
}
|
|
654
|
+
async function selectVideos(videos, options) {
|
|
655
|
+
if (!options.query)
|
|
656
|
+
return videos.slice(0, options.limit == null ? undefined : Number(options.limit));
|
|
657
|
+
const index = (await loadSearchIndex()) ?? (await saveSearchIndex(videos));
|
|
658
|
+
return searchWithIndex(videos, index, { query: options.query, limit: options.limit ?? 50 }).map((result) => result.video);
|
|
659
|
+
}
|
|
660
|
+
function filtersFromOptions(options) {
|
|
661
|
+
return {
|
|
662
|
+
author: optionString(options.author),
|
|
663
|
+
after: optionString(options.after),
|
|
664
|
+
before: optionString(options.before),
|
|
665
|
+
category: optionString(options.category),
|
|
666
|
+
domain: optionString(options.domain),
|
|
667
|
+
collection: optionString(options.collection),
|
|
668
|
+
source: optionString(options.source),
|
|
669
|
+
hasTranscript: typeof options.hasTranscript === "boolean" ? options.hasTranscript : undefined,
|
|
670
|
+
limit: optionNumber(options.limit),
|
|
671
|
+
offset: optionNumber(options.offset),
|
|
672
|
+
query: optionString(options.query),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function formatList(videos) {
|
|
676
|
+
if (videos.length === 0)
|
|
677
|
+
return c.muted("No videos.");
|
|
678
|
+
return videos
|
|
679
|
+
.map((video) => {
|
|
680
|
+
const author = video.author?.username ? c.accent(`@${video.author.username}`) : c.muted("unknown");
|
|
681
|
+
const category = video.classification?.category ? ` ${c.warn(`[${video.classification.category}]`)}` : "";
|
|
682
|
+
const transcript = video.transcript?.text ? ` ${c.success("transcript")}` : "";
|
|
683
|
+
const desc = truncate((video.description ?? "").replace(/\s+/g, " "), 160);
|
|
684
|
+
return [
|
|
685
|
+
`${c.value(video.id)} ${author}${category}${transcript}`,
|
|
686
|
+
` ${desc}`,
|
|
687
|
+
` ${c.muted(video.canonicalUrl ?? video.url)}`,
|
|
688
|
+
].join("\n");
|
|
689
|
+
})
|
|
690
|
+
.join("\n\n");
|
|
691
|
+
}
|
|
692
|
+
function formatVideo(video) {
|
|
693
|
+
return [
|
|
694
|
+
`${c.value(video.id)} ${video.author?.username ? c.accent(`@${video.author.username}`) : ""}`.trimEnd(),
|
|
695
|
+
c.muted(video.canonicalUrl ?? video.url),
|
|
696
|
+
"",
|
|
697
|
+
video.description ?? "",
|
|
698
|
+
"",
|
|
699
|
+
kvList([
|
|
700
|
+
["Category", c.value(video.classification?.category ?? "uncategorized")],
|
|
701
|
+
["Domain", c.value(video.classification?.domain ?? "general")],
|
|
702
|
+
["Topics", (video.classification?.topics ?? []).join(", ") || c.muted("none")],
|
|
703
|
+
["Media", toDisplayPath(video.media?.audioPath ?? video.media?.videoPath) ?? c.muted("not downloaded")],
|
|
704
|
+
]),
|
|
705
|
+
"",
|
|
706
|
+
c.heading("Transcript"),
|
|
707
|
+
video.transcript?.text ?? c.muted("No transcript yet."),
|
|
708
|
+
].join("\n");
|
|
709
|
+
}
|
|
710
|
+
function archiveSummaryLines(videos) {
|
|
711
|
+
const transcriptCount = videos.filter((video) => video.transcript?.text).length;
|
|
712
|
+
const classifiedCount = videos.filter((video) => video.classification?.category).length;
|
|
713
|
+
const dates = videos.flatMap((video) => (video.createdAt ? [video.createdAt] : [])).sort();
|
|
714
|
+
const dot = c.muted("\u00b7");
|
|
715
|
+
const counts = `${c.value(String(videos.length))} videos ${dot} ` +
|
|
716
|
+
`${c.value(String(transcriptCount))} transcripts ${c.muted(`(${percent(transcriptCount, videos.length)})`)} ${dot} ` +
|
|
717
|
+
`${c.value(String(classifiedCount))} classified ${c.muted(`(${percent(classifiedCount, videos.length)})`)}`;
|
|
718
|
+
const range = dates.length
|
|
719
|
+
? `${c.label("Range")} ${formatDate(dates[0])} ${c.muted("\u2192")} ${formatDate(dates.at(-1))}`
|
|
720
|
+
: `${c.label("Range")} ${c.muted("unknown")}`;
|
|
721
|
+
return [counts, range];
|
|
722
|
+
}
|
|
723
|
+
function formatStats(videos) {
|
|
724
|
+
const authors = countBy(videos, (video) => video.author?.username ?? "unknown");
|
|
725
|
+
return [
|
|
726
|
+
box("Tokwise", archiveSummaryLines(videos)),
|
|
727
|
+
"",
|
|
728
|
+
c.heading("Top authors"),
|
|
729
|
+
barChart([...authors.entries()], { limit: 10 }),
|
|
730
|
+
].join("\n");
|
|
731
|
+
}
|
|
732
|
+
function formatViz(videos) {
|
|
733
|
+
const authors = countBy(videos, (video) => video.author?.username ?? "unknown");
|
|
734
|
+
const categories = countBy(videos, (video) => video.classification?.category ?? "uncategorized");
|
|
735
|
+
const domains = countBy(videos, (video) => video.classification?.domain ?? "general");
|
|
736
|
+
return [
|
|
737
|
+
box("Tokwise", archiveSummaryLines(videos)),
|
|
738
|
+
"",
|
|
739
|
+
c.heading("Top authors"),
|
|
740
|
+
barChart([...authors.entries()], { limit: 10 }),
|
|
741
|
+
"",
|
|
742
|
+
c.heading("Categories"),
|
|
743
|
+
barChart([...categories.entries()], { limit: 12 }),
|
|
744
|
+
"",
|
|
745
|
+
c.heading("Domains"),
|
|
746
|
+
barChart([...domains.entries()], { limit: 12 }),
|
|
747
|
+
].join("\n");
|
|
748
|
+
}
|
|
749
|
+
function formatCounts(videos, keyFn) {
|
|
750
|
+
return barChart([...countBy(videos, keyFn).entries()], { limit: 50 });
|
|
751
|
+
}
|
|
752
|
+
function formatDate(iso) {
|
|
753
|
+
if (!iso)
|
|
754
|
+
return "unknown";
|
|
755
|
+
const date = new Date(iso);
|
|
756
|
+
if (Number.isNaN(date.getTime()))
|
|
757
|
+
return iso;
|
|
758
|
+
return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
759
|
+
}
|
|
760
|
+
function countBy(videos, keyFn) {
|
|
761
|
+
const counts = new Map();
|
|
762
|
+
for (const video of videos) {
|
|
763
|
+
const key = keyFn(video);
|
|
764
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
765
|
+
}
|
|
766
|
+
return counts;
|
|
767
|
+
}
|
|
768
|
+
function formatStatus(status) {
|
|
769
|
+
return kvList([
|
|
770
|
+
["Videos", c.value(String(status.videos))],
|
|
771
|
+
["Transcripts", c.value(String(status.transcripts))],
|
|
772
|
+
["Classified", c.value(String(status.classified))],
|
|
773
|
+
["Index", status.indexExists ? c.success("ready") : c.warn("missing")],
|
|
774
|
+
["Data", c.muted(toDisplayPath(status.dataDir) ?? "")],
|
|
775
|
+
["Library", c.muted(toDisplayPath(status.libraryDir) ?? "")],
|
|
776
|
+
]);
|
|
777
|
+
}
|
|
778
|
+
function addLibraryCommands(program) {
|
|
779
|
+
const library = program.command("library").description("Search and manage local library pages");
|
|
780
|
+
library.command("search").argument("<query>").option("--limit <n>", "Max results", parseNumber, 20).action(safe(async (query, options) => {
|
|
781
|
+
const results = await searchLibrary(query, Number(options.limit));
|
|
782
|
+
console.log(results.map((result) => `${result.path} (${result.score})\n ${result.preview}`).join("\n\n") || "No matches.");
|
|
783
|
+
}));
|
|
784
|
+
library.command("show").argument("<path>").option("--json", "JSON output", false).action(safe(async (pagePath, options) => {
|
|
785
|
+
const page = await showLibraryPage(pagePath);
|
|
786
|
+
console.log(options.json ? JSON.stringify(page, null, 2) : page.body);
|
|
787
|
+
if (!options.json)
|
|
788
|
+
console.error(`sha256: ${page.sha256}`);
|
|
789
|
+
}));
|
|
790
|
+
library.command("create").argument("<path>").requiredOption("--stdin", "Read body from stdin").action(safe(async (pagePath) => {
|
|
791
|
+
const file = await createLibraryPage(pagePath, "-");
|
|
792
|
+
console.log(`Created ${toDisplayPath(file)}.`);
|
|
793
|
+
}));
|
|
794
|
+
library.command("update").argument("<path>").requiredOption("--stdin", "Read body from stdin").option("--expected-sha256 <hash>").action(safe(async (pagePath, options) => {
|
|
795
|
+
const file = await updateLibraryPage(pagePath, "-", options.expectedSha256);
|
|
796
|
+
console.log(`Updated ${toDisplayPath(file)}.`);
|
|
797
|
+
}));
|
|
798
|
+
library.command("delete").argument("<path>").action(safe(async (pagePath) => {
|
|
799
|
+
const file = await deleteLibraryPage(pagePath);
|
|
800
|
+
console.log(`Moved to ${toDisplayPath(file)}.`);
|
|
801
|
+
}));
|
|
802
|
+
}
|
|
803
|
+
function addPortableCommandCommands(program) {
|
|
804
|
+
const commands = program.command("commands").description("Manage portable command notes");
|
|
805
|
+
commands.command("list").action(safe(async () => console.log((await listCommands()).join("\n") || "No commands.")));
|
|
806
|
+
commands.command("new").argument("<name>").action(safe(async (name) => console.log(`Created ${toDisplayPath(await createCommand(name))}.`)));
|
|
807
|
+
commands.command("validate").argument("[name]").action(safe(async (name) => {
|
|
808
|
+
const result = await validateCommands(name);
|
|
809
|
+
if (result.ok.length)
|
|
810
|
+
console.log(`OK: ${result.ok.join(", ")}`);
|
|
811
|
+
if (result.issues.length) {
|
|
812
|
+
console.log(result.issues.join("\n"));
|
|
813
|
+
process.exitCode = 1;
|
|
814
|
+
}
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
function addSkillCommands(program) {
|
|
818
|
+
const skill = program.command("skill").description("Install or show the agent skill");
|
|
819
|
+
skill.command("show").action(() => console.log(skillContent()));
|
|
820
|
+
skill.command("install").option("--target <target>", "codex, claude, or all", "all").action(safe(async (options) => {
|
|
821
|
+
const files = await installSkill(options.target);
|
|
822
|
+
console.log(files.map((file) => `Installed ${toDisplayPath(file)}`).join("\n"));
|
|
823
|
+
}));
|
|
824
|
+
skill.command("uninstall").option("--target <target>", "codex, claude, or all", "all").action(safe(async (options) => {
|
|
825
|
+
const files = await uninstallSkill(options.target);
|
|
826
|
+
console.log(files.map((file) => `Removed ${toDisplayPath(file)}`).join("\n"));
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
function optionString(value) {
|
|
830
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
831
|
+
}
|
|
832
|
+
function optionNumber(value) {
|
|
833
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
834
|
+
}
|
|
835
|
+
function parseNumber(value) {
|
|
836
|
+
const parsed = Number(value);
|
|
837
|
+
if (!Number.isFinite(parsed))
|
|
838
|
+
throw new Error(`Invalid number: ${value}`);
|
|
839
|
+
return parsed;
|
|
840
|
+
}
|
|
841
|
+
function percent(part, total) {
|
|
842
|
+
return total === 0 ? "0%" : `${Math.round((part / total) * 100)}%`;
|
|
843
|
+
}
|
|
844
|
+
function slug(value) {
|
|
845
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "answer";
|
|
846
|
+
}
|
|
847
|
+
async function fileExists(filePath) {
|
|
848
|
+
try {
|
|
849
|
+
await fs.access(filePath);
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
async function showDashboard() {
|
|
857
|
+
ensureDataDirs();
|
|
858
|
+
const videos = await loadVideos();
|
|
859
|
+
console.log([
|
|
860
|
+
"",
|
|
861
|
+
box(`Tokwise CLI v${version()}`, archiveSummaryLines(videos)),
|
|
862
|
+
"",
|
|
863
|
+
formatStatus({
|
|
864
|
+
videos: videos.length,
|
|
865
|
+
transcripts: videos.filter((video) => video.transcript?.text).length,
|
|
866
|
+
classified: videos.filter((video) => video.classification?.category).length,
|
|
867
|
+
dataDir: dataDir(),
|
|
868
|
+
libraryDir: libraryDir(),
|
|
869
|
+
indexExists: await fileExists(searchIndexPath()),
|
|
870
|
+
}),
|
|
871
|
+
"",
|
|
872
|
+
`${c.muted("Next")} tokwise sync --collection <url> --download --audio --transcribe --classify`,
|
|
873
|
+
`${c.muted("Explore")} tokwise search "life advice" ${c.muted("|")} tokwise viz ${c.muted("|")} tokwise wiki`,
|
|
874
|
+
].join("\n"));
|
|
875
|
+
}
|
|
876
|
+
export function isCliEntrypoint(importUrl = import.meta.url, argvPath = process.argv[1]) {
|
|
877
|
+
if (!argvPath)
|
|
878
|
+
return false;
|
|
879
|
+
try {
|
|
880
|
+
return realpathSync(fileURLToPath(importUrl)) === realpathSync(argvPath);
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
return path.resolve(fileURLToPath(importUrl)) === path.resolve(argvPath);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (isCliEntrypoint()) {
|
|
887
|
+
const program = buildCli();
|
|
888
|
+
if (process.argv.length <= 2) {
|
|
889
|
+
await showDashboard();
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
await program.parseAsync(process.argv);
|
|
893
|
+
}
|
|
894
|
+
}
|