diff --git a/README.md b/README.md index 20c74f7..e15bc40 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,26 @@ podflow digest | `podflow digest --backfill` | Process all episodes | | `podflow subs` | List podcast subscriptions | | `podflow stats` | Cache statistics | +| `podflow schedule` | Weekly automatic digests + notification (macOS) | | `podflow mcp` | Start the MCP server (stdio) — ears for agents | +## Set it and forget it + +`podflow init` now walks you through setup interactively (provider, key, interests) — +no JSON editing. Then: + +```bash +podflow schedule # weekly, Mondays 08:00 +podflow schedule --daily --time 07:30 +podflow schedule --status # check it +podflow schedule --off # remove it +``` + +Each scheduled run processes new episodes and ends with a macOS notification, so the +digest reaches you instead of waiting in a file. Your API key is stored in +`~/.podflow/config.json` (permissions 0600) so scheduled runs work without shell +environment variables. + ## Agents can use it too (MCP) podflow ships an [MCP](https://modelcontextprotocol.io) server, so AI agents can query diff --git a/src/ai/extractor.ts b/src/ai/extractor.ts index 2d58702..9385ff1 100644 --- a/src/ai/extractor.ts +++ b/src/ai/extractor.ts @@ -63,15 +63,15 @@ function buildEpisodeInput(config: PodflowConfig, ep: DetailedEpisode, index: nu function getModel(config: PodflowConfig) { switch (config.provider) { case 'anthropic': { - const anthropic = createAnthropic(); + const anthropic = createAnthropic(config.apiKey ? { apiKey: config.apiKey } : undefined); return anthropic(config.model || 'claude-haiku-4-5-20251001'); } case 'openai': { - const openai = createOpenAI(); + const openai = createOpenAI(config.apiKey ? { apiKey: config.apiKey } : undefined); return openai(config.model || 'gpt-4o-mini'); } case 'google': { - const google = createGoogleGenerativeAI(); + const google = createGoogleGenerativeAI(config.apiKey ? { apiKey: config.apiKey } : undefined); return google(config.model || 'gemini-2.0-flash'); } case 'ollama': { diff --git a/src/cli.ts b/src/cli.ts index 5e1d0f6..b2e813f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,11 +15,19 @@ const VERSION = '0.2.0'; // ── init ─────────────────────────────────────────────────────────── +const PROVIDER_CHOICES = [ + { key: 'anthropic', label: 'Anthropic (Claude Haiku — recommended)', envVar: 'ANTHROPIC_API_KEY' }, + { key: 'openai', label: 'OpenAI (gpt-4o-mini)', envVar: 'OPENAI_API_KEY' }, + { key: 'google', label: 'Google (Gemini Flash)', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY' }, + { key: 'ollama', label: 'Ollama (local, free, no key)', envVar: '' }, +] as const; + function registerInit(program: Command): void { program .command('init') - .description('Create config at ~/.podflow/') - .action(async () => { + .description('Set up podflow (interactive) — config lands at ~/.podflow/') + .option('-y, --yes', 'Skip the wizard and write defaults (old behaviour)') + .action(async (opts) => { ui.intro(VERSION); if (configExists()) { @@ -31,15 +39,89 @@ function registerInit(program: Command): void { return; } - initConfig(); - ui.stepComplete('Config created at ~/.podflow/'); - ui.blank(); - ui.nextSteps([ - { cmd: 'nano ~/.podflow/config.json', desc: 'Set your interests' }, - { cmd: 'export ANTHROPIC_API_KEY=...', desc: 'Set your API key' }, - { cmd: 'podflow digest --dry-run', desc: 'Preview episodes' }, - ]); - ui.blank(); + // Non-interactive path: --yes flag, or no TTY (CI, scripts, agents). + if (opts.yes || !process.stdin.isTTY) { + initConfig(); + ui.stepComplete('Config created at ~/.podflow/ (defaults)'); + ui.blank(); + ui.nextSteps([ + { cmd: 'nano ~/.podflow/config.json', desc: 'Set your interests' }, + { cmd: 'export ANTHROPIC_API_KEY=...', desc: 'Set your API key' }, + { cmd: 'podflow digest --dry-run', desc: 'Preview episodes' }, + ]); + ui.blank(); + return; + } + + // Interactive wizard — a human's first five minutes should not involve + // hand-editing JSON. + const { createInterface } = await import('node:readline/promises'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const ask = async (q: string, fallback = ''): Promise => { + const answer = (await rl.question(q)).trim(); + return answer || fallback; + }; + + try { + const config = initConfig(); + + console.log(''); + const about = await ask( + 'What do you do? (one line, used to judge relevance)\n> ', + config.about + ); + config.about = about; + + console.log('\nWhich AI provider?'); + PROVIDER_CHOICES.forEach((p, i) => console.log(` ${i + 1}. ${p.label}`)); + const provIdx = parseInt(await ask('> ', '1'), 10); + const chosen = PROVIDER_CHOICES[Math.min(Math.max(provIdx, 1), 4) - 1]; + config.provider = chosen.key; + config.model = ''; // let getModel pick the provider default + + if (chosen.envVar) { + const envKey = process.env[chosen.envVar]; + if (envKey) { + const useEnv = await ask( + `\nFound ${chosen.envVar} in your environment. Use it? (Y/n) > `, + 'y' + ); + if (useEnv.toLowerCase().startsWith('y')) { + // Store in config so scheduled runs work without shell env. + config.apiKey = envKey; + } + } + if (!config.apiKey) { + const pasted = await ask( + `\nPaste your ${chosen.envVar.replace(/_/g, ' ').toLowerCase()} (stored in ~/.podflow/config.json, chmod 600; leave blank to use the env var later)\n> ` + ); + if (pasted) config.apiKey = pasted; + } + } + + const interests = await ask( + '\nYour interest topics, comma-separated (blank keeps sensible defaults)\n> ' + ); + if (interests) { + config.interests = interests.split(',').map((raw) => { + const name = raw.trim(); + return { name, keywords: [name.toLowerCase()], why: `Interested in ${name}.` }; + }); + } + + saveConfig(config); + console.log(''); + ui.stepComplete('Config created at ~/.podflow/'); + ui.blank(); + ui.nextSteps([ + { cmd: 'podflow digest --dry-run', desc: 'Preview episodes (no API calls)' }, + { cmd: 'podflow digest --max-episodes 10', desc: 'Run your first digest' }, + { cmd: 'podflow schedule', desc: 'Get a digest every week, automatically' }, + ]); + ui.blank(); + } finally { + rl.close(); + } }); } @@ -61,6 +143,7 @@ function registerDigest(program: Command): void { .option('--verbose', 'Show episode details') .option('--rss ', 'Process a specific RSS feed URL') .option('-q, --quiet', 'Suppress output except errors') + .option('--scheduled', 'Scheduled-run mode: end with a macOS notification') .action(async (opts) => { const startTime = Date.now(); @@ -90,6 +173,8 @@ function registerDigest(program: Command): void { // ── Load cache ── let cache = loadCache(); + // Snapshot for this run's delta (used by the --scheduled notification). + const statsBefore = { ...cache.stats }; // ── Query ── let allEpisodes: DetailedEpisode[] = []; @@ -153,15 +238,21 @@ function registerDigest(program: Command): void { if (toProcess.length === 0) { if (!opts.quiet) ui.blank(); + let outPath = config.outputPath; if (cache.stats.totalProcessed > 0) { const regen = ui.createSpinner('Regenerating digest...'); regen.start(); - const outPath = generateDigest(cache, config); + outPath = generateDigest(cache, config); regen.succeed('Digest regenerated'); ui.outputPath(outPath); } else { ui.step('Nothing to process.'); } + // Scheduled runs notify even when quiet — most weeks land here. + if (opts.scheduled) { + const { notifyDigest } = await import('./schedule.js'); + notifyDigest(0, 0, 0, outPath); + } if (!opts.quiet) ui.outro(Date.now() - startTime); return; } @@ -311,6 +402,17 @@ function registerDigest(program: Command): void { digestSpinner.succeed('Digest written'); ui.outputPath(outPath); + // The human loop: scheduled runs end with a notification, not a silent file. + if (opts.scheduled) { + const { notifyDigest } = await import('./schedule.js'); + notifyDigest( + cache.stats.totalProcessed - statsBefore.totalProcessed, + cache.stats.totalGuests - statsBefore.totalGuests, + cache.stats.totalIdeas - statsBefore.totalIdeas, + outPath + ); + } + if (!opts.quiet) { ui.blank(); ui.nextSteps([ @@ -466,6 +568,71 @@ function registerImport(program: Command): void { }); } +// ── schedule ─────────────────────────────────────────────────────── + +function registerSchedule(program: Command): void { + program + .command('schedule') + .description('Run the digest automatically (weekly by default) and get notified — macOS') + .option('--daily', 'Run every day instead of weekly') + .option('--time ', 'Time of day (24h)', '08:00') + .option('--max-episodes ', 'Episodes per scheduled run', '15') + .option('--off', 'Remove the schedule') + .option('--status', 'Show schedule status') + .action(async (opts) => { + const { installSchedule, removeSchedule, scheduleStatus } = await import('./schedule.js'); + ui.intro(VERSION); + + if (opts.status) { + const s = scheduleStatus(); + if (!s.installed) { + ui.hint('No schedule installed. Run `podflow schedule` to set one up.'); + } else { + ui.stepComplete(`Schedule installed (${s.loaded ? 'loaded' : 'NOT loaded — try re-running podflow schedule'})`); + ui.hint(`Plist: ${s.plistPath}`); + ui.hint('Logs: ~/.podflow/logs/schedule.log'); + } + ui.blank(); + return; + } + + if (opts.off) { + const existed = removeSchedule(); + ui.stepComplete(existed ? 'Schedule removed' : 'No schedule was installed'); + ui.blank(); + return; + } + + if (!configExists()) { + ui.error('No config found. Run `podflow init` first.'); + process.exit(1); + } + const config = loadConfig(); + if (!config.apiKey && config.provider !== 'ollama') { + ui.error( + 'Scheduled runs need an API key in ~/.podflow/config.json (launchd cannot see your shell env).\n' + + 'Re-run `podflow init` after removing the config, or add "apiKey": "..." to config.json.' + ); + process.exit(1); + } + + try { + const { summary } = installSchedule({ + daily: Boolean(opts.daily), + time: opts.time, + maxEpisodes: parseInt(opts.maxEpisodes, 10) || 15, + }); + ui.stepComplete('Schedule installed'); + ui.hint(summary); + ui.hint('Check anytime: podflow schedule --status | remove: podflow schedule --off'); + ui.blank(); + } catch (err) { + ui.error((err as Error).message); + process.exit(1); + } + }); +} + // ── mcp ──────────────────────────────────────────────────────────── function registerMcp(program: Command): void { @@ -493,6 +660,7 @@ registerSubs(program); registerStats(program); registerUi(program); registerImport(program); +registerSchedule(program); registerMcp(program); program.parse(); diff --git a/src/config/index.ts b/src/config/index.ts index 18fe02c..7d986e3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -67,7 +67,9 @@ export function saveConfig(config: PodflowConfig): void { fs.mkdirSync(CONFIG_DIR, { recursive: true }); const { podcasts, ...rest } = config; - fs.writeFileSync(CONFIG_FILE, JSON.stringify(rest, null, 2)); + // 0600: config.json may hold the provider API key (see PodflowConfig.apiKey). + fs.writeFileSync(CONFIG_FILE, JSON.stringify(rest, null, 2), { mode: 0o600 }); + fs.chmodSync(CONFIG_FILE, 0o600); fs.writeFileSync(PODCASTS_FILE, JSON.stringify(podcasts, null, 2)); } diff --git a/src/schedule.ts b/src/schedule.ts new file mode 100644 index 0000000..cf84b39 --- /dev/null +++ b/src/schedule.ts @@ -0,0 +1,146 @@ +/** + * podflow schedule — the human loop (macOS). + * + * Installs a launchd LaunchAgent that runs `podflow digest --scheduled` weekly + * (or daily), so the digest arrives without anyone remembering to run a CLI. + * The --scheduled flag makes digest end with a macOS notification, which is + * where a human actually notices it. + * + * launchd gotchas handled here (hard-won elsewhere): + * - Jobs get a minimal environment: PATH must be set explicitly in the plist. + * - The provider API key comes from ~/.podflow/config.json (see + * PodflowConfig.apiKey), NOT shell env — launchd never sees your shell. + * - Absolute paths only: node binary (process.execPath) + resolved CLI script. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; + +const LABEL = 'dev.podflow.digest'; +const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`); +const LOG_DIR = path.join(os.homedir(), '.podflow', 'logs'); + +export interface ScheduleOptions { + daily: boolean; + time: string; // HH:MM + maxEpisodes: number; +} + +function xmlEscape(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function buildPlist(opts: ScheduleOptions): string { + const [hourRaw, minuteRaw] = opts.time.split(':'); + const hour = parseInt(hourRaw, 10); + const minute = parseInt(minuteRaw ?? '0', 10); + if (Number.isNaN(hour) || hour < 0 || hour > 23 || Number.isNaN(minute) || minute < 0 || minute > 59) { + throw new Error(`Invalid --time "${opts.time}" — use HH:MM (24h), e.g. 08:00`); + } + + const nodeBin = process.execPath; + const cliScript = path.resolve(process.argv[1]); + + const calendar = opts.daily + ? ` + Hour${hour} + Minute${minute} + ` + : ` + Weekday1 + Hour${hour} + Minute${minute} + `; + + return ` + + + + Label${LABEL} + ProgramArguments + + ${xmlEscape(nodeBin)} + ${xmlEscape(cliScript)} + digest + --max-episodes + ${opts.maxEpisodes} + --scheduled + + StartCalendarInterval + +${calendar} + + EnvironmentVariables + + PATH/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin + HOME${xmlEscape(os.homedir())} + + StandardOutPath${xmlEscape(path.join(LOG_DIR, 'schedule.log'))} + StandardErrorPath${xmlEscape(path.join(LOG_DIR, 'schedule.log'))} + RunAtLoad + + +`; +} + +function launchctl(args: string[]): string { + try { + return execFileSync('launchctl', args, { encoding: 'utf-8' }); + } catch (err) { + return (err as { stdout?: string; message: string }).stdout ?? ''; + } +} + +export function installSchedule(opts: ScheduleOptions): { plistPath: string; summary: string } { + if (process.platform !== 'darwin') { + throw new Error('podflow schedule uses launchd and is macOS-only for now.'); + } + fs.mkdirSync(LOG_DIR, { recursive: true }); + fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true }); + + // Re-arm cleanly if already installed. + removeSchedule({ quiet: true }); + + fs.writeFileSync(PLIST_PATH, buildPlist(opts)); + launchctl(['bootstrap', `gui/${process.getuid?.() ?? 501}`, PLIST_PATH]); + + const cadence = opts.daily ? `daily at ${opts.time}` : `weekly (Mondays at ${opts.time})`; + return { + plistPath: PLIST_PATH, + summary: `Digest will run ${cadence}, up to ${opts.maxEpisodes} episodes, and notify you when it lands.`, + }; +} + +export function removeSchedule({ quiet = false } = {}): boolean { + const existed = fs.existsSync(PLIST_PATH); + if (existed) { + launchctl(['bootout', `gui/${process.getuid?.() ?? 501}/${LABEL}`]); + fs.rmSync(PLIST_PATH); + } else if (!quiet) { + // nothing installed — caller reports + } + return existed; +} + +export function scheduleStatus(): { installed: boolean; loaded: boolean; plistPath: string } { + const installed = fs.existsSync(PLIST_PATH); + const loaded = installed && launchctl(['list']).includes(LABEL); + return { installed, loaded, plistPath: PLIST_PATH }; +} + +/** macOS notification — the "delivery" half of the human loop. */ +export function notifyDigest(processed: number, guests: number, ideas: number, outputPath: string): void { + if (process.platform !== 'darwin') return; + const body = + processed === 0 + ? 'No new episodes since last run.' + : `${processed} episodes → ${guests} guests, ${ideas} ideas. Digest updated.`; + const script = `display notification ${JSON.stringify(body)} with title "podflow" subtitle ${JSON.stringify(path.basename(outputPath))}`; + try { + execFileSync('osascript', ['-e', script]); + } catch { + // Notifications are best-effort; the digest file is still written. + } +} diff --git a/src/types.ts b/src/types.ts index af98de0..84d1310 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,4 +125,11 @@ export interface PodflowConfig { model: string; outputPath: string; feeds?: string[]; + /** + * Optional provider API key stored in ~/.podflow/config.json (written 0600). + * When absent, the provider SDK falls back to its environment variable + * (ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY). + * Storing it here lets scheduled (launchd) runs work without shell env. + */ + apiKey?: string; }