diff --git a/skills/c-focus/SKILL.md b/skills/c-focus/SKILL.md new file mode 100644 index 0000000..5d85252 --- /dev/null +++ b/skills/c-focus/SKILL.md @@ -0,0 +1,153 @@ +--- +name: c-focus +description: Focus Mode — orchestrate distraction blocking, environment setup, and session tracking. +tags: [focus, productivity, deep-work, pomodoro, distraction-blocking] +--- + +## What This Skill Does + +You orchestrate focus sessions by reading the user's config and running shell commands directly. + +## Config + +Read `~/.config/openpaw/focus.json` for preferences. If missing, suggest: `openpaw focus setup` + +```json +{ + "duration": 90, + "bluetooth": { "device": "AirPods Pro" }, + "music": { "source": "spotify", "query": "lo-fi beats" }, + "blockedSites": { + "always": ["x.com", "reddit.com"], + "askEachTime": ["youtube.com"] + }, + "quitApps": { + "always": ["Messages", "Mail"], + "askEachTime": ["Discord"] + }, + "lights": { "room": "Office", "brightness": 30, "color": "warm" }, + "dnd": true, + "slackDnd": true, + "timer": true, + "obsidianLog": true +} +``` + +## Starting a Focus Session + +When the user says "focus", "deep work", "lock in", or similar: + +1. Read `~/.config/openpaw/focus.json` +2. Check `~/.config/openpaw/focus-session.json` — if it exists, a session is already active +3. If there are `askEachTime` sites or apps, ask the user which to include this session +4. Tell the user what you're about to do, then execute each enabled step: + +### Commands to Run (in order) + +**Block websites** (if `blockedSites` configured): +```bash +# For each site in always + user-approved askEachTime list: +echo "127.0.0.1 site.com # OPENPAW-FOCUS +127.0.0.1 www.site.com # OPENPAW-FOCUS" | sudo tee -a /etc/hosts > /dev/null +sudo dscacheutil -flushcache +sudo killall -HUP mDNSResponder +``` + +**Quit apps** (if `quitApps` configured): +```bash +osascript -e 'quit app "AppName"' +``` + +**Connect bluetooth** (if `bluetooth` configured): +```bash +blu connect "device name" +``` + +**Play music** (if `music` configured): +```bash +# spotify: +spogo search playlist "query" --play +# apple-music: +osascript -e 'tell application "Music" to play playlist "query"' +# sonos: +sonos play "query" +# youtube (yt-dlp — prefix non-URLs with ytsearch1:): +yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "ytsearch1:query" && afplay /tmp/openpaw-focus.mp3 & +``` + +**Set lights** (if `lights` configured): +```bash +openhue set room "room" --on --brightness N --color "color" +``` + +**Enable DND** (if `dnd: true`): +```bash +defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean true +killall NotificationCenter +``` + +**Slack DND** (if `slackDnd: true`): +```bash +slack dnd set +``` + +**Write the session file** to `~/.config/openpaw/focus-session.json`: +```json +{ + "startedAt": "", + "endsAt": "", + "config": { ... }, + "blockedSiteAttempts": 0, + "gitCommitsBefore": +} +``` + +**Start the auto-end timer:** +```bash +openpaw focus auto-end & +``` +This sleeps for the duration, then restores everything and sends a summary via Telegram. + +Or run `openpaw focus start` to do all of the above automatically. + +## Ending a Focus Session + +When the user says "stop focus", "end focus", "I'm done", or the timer fires: + +1. **Restore environment:** +```bash +# Unblock sites +sudo sed -i '' '/OPENPAW-FOCUS/d' /etc/hosts +sudo dscacheutil -flushcache +# Disable DND +defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean false +killall NotificationCenter +# Stop music (use the matching command for the source) +spogo pause +``` + +2. **Generate receipt:** +```bash +git rev-list --count HEAD # subtract gitCommitsBefore from session file +git diff --stat HEAD~N HEAD +``` + +3. **Summarize naturally** — how long they focused, commits made, lines added/removed. Be encouraging. +4. **Log to Obsidian** if `obsidianLog: true` +5. Delete `~/.config/openpaw/focus-session.json` + +## Reconfigure + +```bash +openpaw focus setup # Interactive wizard +openpaw focus configure # Alias +``` + +## Guidelines + +- Only start focus when the user explicitly asks — never suggest unprompted +- Always tell the user what you're doing before each step +- If a command fails (e.g. sudo denied), tell the user and continue with other steps +- Skip any step whose config field is missing or false +- Reference SOUL.md for personal preferences +- When ending, write a human summary — don't just dump numbers diff --git a/src/catalog/index.ts b/src/catalog/index.ts index b4036a7..51b7fe0 100644 --- a/src/catalog/index.ts +++ b/src/catalog/index.ts @@ -363,7 +363,6 @@ export const skills: Skill[] = [ platforms: ["darwin", "linux", "win32"], depends: ["email", "calendar"], }, - // ── Browser & Automation ── { id: "browser", diff --git a/src/commands/configure.ts b/src/commands/configure.ts index dc6c8a2..fce1730 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -19,7 +19,8 @@ export async function configureCommand(): Promise { { value: "soul", label: "Edit personality", hint: "name, tone, verbosity" }, { value: "dashboard", label: "Open dashboard", hint: "task manager in browser" }, { value: "telegram", label: "Telegram setup", hint: "configure bot bridge" }, - { value: "schedule", label: "Manage schedules", hint: "recurring tasks + cost control" }, + { value: "focus setup", label: "Focus Mode", hint: "block distractions, set the mood" }, + { value: "schedule", label: "Manage schedules", hint: "recurring tasks + cost control" }, { value: "status", label: "View status", hint: "see what's installed" }, { value: "doctor", label: "Run diagnostics", hint: "check for issues" }, ], diff --git a/src/commands/focus.ts b/src/commands/focus.ts new file mode 100644 index 0000000..2b0c6c5 --- /dev/null +++ b/src/commands/focus.ts @@ -0,0 +1,948 @@ +import * as p from "@clack/prompts"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { spawn } from "node:child_process"; +import { showMini, accent, dim, bold, subtle } from "../core/branding.js"; +import { getDefaultSkillsDir, listInstalledSkills } from "../core/skills.js"; +import { + readFocusConfig, + writeFocusConfig, + focusConfigExists, + detectCapabilities, + readFocusSession, + writeFocusSession, + clearFocusSession, + blockSites, + unblockSites, + quitApps, + enableDnd, + disableDnd, + enableSlackDnd, + setLights, + connectBluetooth, + startMusic, + stopMusic, + getGitCommitCount, + getGitDiffStats, + sendNotification, + logToObsidian, + COMMON_BLOCKED_SITES, + COMMON_QUIT_APPS, +} from "../core/focus.js"; +import type { FocusConfig, FocusMusicSource } from "../types.js"; + +// ── Focus (show config + status) ── + +export function focusCommand(): void { + showMini(); + console.log(""); + + const config = readFocusConfig(); + if (!config) { + p.log.warn("Focus Mode isn't set up yet."); + p.log.info(`Run ${bold("openpaw focus setup")} to configure your focus environment.`); + return; + } + + // Check for active session + const session = readFocusSession(); + if (session) { + const endsAt = new Date(session.endsAt); + const now = new Date(); + if (endsAt > now) { + const remaining = Math.round((endsAt.getTime() - now.getTime()) / 60000); + const elapsed = Math.round((now.getTime() - new Date(session.startedAt).getTime()) / 60000); + p.note( + [ + `${bold("Status:")} ${accent("active")}`, + `${bold("Elapsed:")} ${elapsed} min`, + `${bold("Remaining:")} ${remaining} min`, + `${bold("Ends at:")} ${endsAt.toLocaleTimeString()}`, + ].join("\n"), + "Focus Session", + ); + p.log.info(dim("Claude is managing this session. Tell Claude to end it, or run ") + bold("openpaw focus end")); + return; + } + // Session expired + p.log.warn("Previous session expired. Run " + bold("openpaw focus end") + " to clean up."); + } + + // Show config + printConfig(config); + console.log(""); + p.log.info(dim("Tell Claude to ") + bold("focus") + dim(" or ") + bold("lock in") + dim(" to start a session.")); + p.log.info(dim("Reconfigure: ") + bold("openpaw focus setup")); +} + +function scheduleEndNotification(minutes: number): void { + const seconds = minutes * 60; + try { + const child = spawn("sh", ["-c", `sleep ${seconds} && terminal-notifier -title "Focus Complete" -message "Your ${minutes}-minute focus session is done!" -sound default`], { + detached: true, + stdio: "ignore", + }); + child.unref(); + } catch {} +} + +function scheduleFocusEndSession(minutes: number): void { + const seconds = minutes * 60; + // Resolve the CLI entry point so it works from any cwd + const cli = path.resolve(process.argv[1]); + try { + // Sleep N minutes, then run `openpaw focus auto-end` which spawns a Claude session + const child = spawn("sh", ["-c", `sleep ${seconds} && node "${cli}" focus auto-end`], { + detached: true, + stdio: "ignore", + env: { ...process.env }, + }); + child.unref(); + } catch {} +} + +/** + * Auto-end: spawns a Claude session that ends the focus, reads the receipt, + * and sends a natural summary via Telegram (or saves to file). + */ +export async function focusAutoEndCommand(): Promise { + const config = readFocusConfig(); + const session = readFocusSession(); + if (!session || !config) return; + + // Restore environment first + restoreEnvironment(config); + + // Generate receipt data + const startTime = new Date(session.startedAt); + const elapsed = Math.round((Date.now() - startTime.getTime()) / 60000); + const stats = getGitDiffStats(session.gitCommitsBefore); + + const receipt = [ + `Duration: ${elapsed} min`, + `Commits: ${stats.commits}`, + `Lines added: +${stats.linesAdded}`, + `Lines removed: -${stats.linesRemoved}`, + ].join("\n"); + + // Obsidian log + if (config.obsidianLog) { + logToObsidian(elapsed, stats); + } + + clearFocusSession(); + + // Spawn a Claude session to write a natural summary + try { + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + const prompt = `The user's focus session just ended. Here are the stats:\n\n${receipt}\n\nWrite a brief, encouraging 2-3 sentence summary of their focus session. Be specific about the numbers. If they made commits, acknowledge their productivity. If not, that's fine too — they were focused. Keep it casual and warm.`; + + let summary = ""; + const q = query({ + prompt, + options: { + model: "claude-haiku-4-5-20251001", + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 1, + }, + }); + + for await (const message of q) { + if (message.type === "result") { + const result = message as { result?: string }; + if (result.result) summary = result.result; + } + } + + if (!summary) summary = `Focus session complete: ${elapsed} min, ${stats.commits} commits, +${stats.linesAdded}/-${stats.linesRemoved} lines.`; + + // Deliver via Telegram if available, otherwise notification + try { + const { readTelegramConfig } = await import("../core/telegram.js"); + const tgConfig = readTelegramConfig(); + if (tgConfig) { + for (const userId of tgConfig.allowedUserIds) { + await fetch(`https://api.telegram.org/bot${tgConfig.botToken}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: userId, text: `🎯 *Focus Complete*\n\n${summary}`, parse_mode: "Markdown" }), + }); + } + return; + } + } catch {} + + // Fallback: native notification + sendNotification("Focus Complete", summary.slice(0, 200)); + } catch { + // SDK not available — just send basic notification + sendNotification("Focus Complete", `${elapsed} min session done. ${stats.commits} commits.`); + } +} + +// ── Non-interactive commands (for Claude Code) ── + +export function focusStartCommand(opts: { all?: boolean }): void { + const config = readFocusConfig(); + if (!config) { + console.log("Focus Mode not configured. Run: openpaw focus setup"); + process.exit(1); + } + + const session = readFocusSession(); + if (session) { + const endsAt = new Date(session.endsAt); + if (endsAt > new Date()) { + const remaining = Math.round((endsAt.getTime() - Date.now()) / 60000); + console.log(`Focus session already active. ${remaining} min remaining.`); + console.log(`Run "openpaw focus end" to stop early.`); + return; + } + // Expired — clean up silently + restoreEnvironment(config); + clearFocusSession(); + } + + const actions: string[] = []; + + // Block sites + const sites = opts.all + ? [...(config.blockedSites?.always ?? []), ...(config.blockedSites?.askEachTime ?? [])] + : [...(config.blockedSites?.always ?? [])]; + if (sites.length > 0) { + blockSites(sites); + actions.push(`Blocked ${sites.length} sites`); + } + + // Quit apps + const apps = opts.all + ? [...(config.quitApps?.always ?? []), ...(config.quitApps?.askEachTime ?? [])] + : [...(config.quitApps?.always ?? [])]; + if (apps.length > 0) { + quitApps(apps); + actions.push(`Quit ${apps.length} apps: ${apps.join(", ")}`); + } + + // Bluetooth + if (config.bluetooth?.device) { + connectBluetooth(config.bluetooth.device); + actions.push(`Connected ${config.bluetooth.device}`); + } + + // Music + if (config.music) { + startMusic(config.music); + actions.push(`Playing ${config.music.source}: ${config.music.query}`); + } + + // Lights + if (config.lights) { + setLights(config.lights.room, config.lights.brightness, config.lights.color); + actions.push(`Set ${config.lights.room} lights to ${config.lights.brightness}%`); + } + + // DND + if (config.dnd) { + enableDnd(); + actions.push("Enabled Do Not Disturb"); + } + + // Slack DND + if (config.slackDnd) { + enableSlackDnd(config.duration); + actions.push(`Set Slack DND for ${config.duration} min`); + } + + // Save session + const now = new Date(); + writeFocusSession({ + startedAt: now.toISOString(), + endsAt: new Date(now.getTime() + config.duration * 60000).toISOString(), + config, + blockedSiteAttempts: 0, + gitCommitsBefore: getGitCommitCount(), + }); + + // Timer + if (config.timer) { + sendNotification("Focus Mode", `${config.duration} minutes starts now.`); + scheduleEndNotification(config.duration); + } + + // Schedule auto-end: Claude session fires when time is up + scheduleFocusEndSession(config.duration); + + // Plain text output for Claude + console.log(`Focus session started (${config.duration} min)`); + for (const a of actions) console.log(` - ${a}`); + console.log(`\nEnds at: ${new Date(now.getTime() + config.duration * 60000).toLocaleTimeString()}`); + console.log(`Run "openpaw focus end" when done.`); +} + +export function focusEndCommand(): void { + const config = readFocusConfig(); + const session = readFocusSession(); + + if (!session) { + console.log("No active focus session."); + return; + } + + if (config) { + restoreEnvironment(config); + } + + // Generate receipt + const startTime = new Date(session.startedAt); + const elapsed = Math.round((Date.now() - startTime.getTime()) / 60000); + const stats = getGitDiffStats(session.gitCommitsBefore); + + console.log("Focus session ended.\n"); + console.log("--- Focus Receipt ---"); + console.log(`Duration: ${elapsed} min`); + console.log(`Commits: ${stats.commits}`); + console.log(`Lines added: +${stats.linesAdded}`); + console.log(`Lines removed: -${stats.linesRemoved}`); + if (config?.blockedSites) { + const total = (config.blockedSites.always?.length ?? 0) + (config.blockedSites.askEachTime?.length ?? 0); + console.log(`Sites blocked: ${total}`); + } + console.log("---------------------"); + + // Obsidian log + if (config?.obsidianLog) { + logToObsidian(elapsed, stats); + console.log("Logged to Obsidian."); + } + + // Notification + if (config?.timer) { + sendNotification("Focus Complete", `${elapsed} min session. ${stats.commits} commits.`); + } + + clearFocusSession(); +} + +export function focusStatusCommand(): void { + const config = readFocusConfig(); + if (!config) { + console.log("Focus Mode not configured. Run: openpaw focus setup"); + return; + } + + const session = readFocusSession(); + if (!session) { + console.log("No active focus session."); + console.log(`\nConfig: ${config.duration} min sessions`); + if (config.blockedSites) console.log(` Sites: ${config.blockedSites.always.length} always, ${config.blockedSites.askEachTime.length} ask-each-time`); + if (config.quitApps) console.log(` Apps: ${config.quitApps.always.length} always, ${config.quitApps.askEachTime.length} ask-each-time`); + if (config.music) console.log(` Music: ${config.music.source} → ${config.music.query}`); + if (config.bluetooth) console.log(` Bluetooth: ${config.bluetooth.device}`); + if (config.lights) console.log(` Lights: ${config.lights.room} at ${config.lights.brightness}%`); + console.log(`\nRun "openpaw focus start" to begin.`); + return; + } + + const endsAt = new Date(session.endsAt); + const now = new Date(); + + if (endsAt > now) { + const remaining = Math.round((endsAt.getTime() - now.getTime()) / 60000); + const elapsed = Math.round((now.getTime() - new Date(session.startedAt).getTime()) / 60000); + console.log(`Focus session active.`); + console.log(` Elapsed: ${elapsed} min`); + console.log(` Remaining: ${remaining} min`); + console.log(` Ends at: ${endsAt.toLocaleTimeString()}`); + } else { + console.log("Focus session expired. Run \"openpaw focus end\" to clean up and see receipt."); + } +} + +function restoreEnvironment(config: FocusConfig): void { + if (config.blockedSites && (config.blockedSites.always.length > 0 || config.blockedSites.askEachTime.length > 0)) { + unblockSites(); + } + if (config.dnd) disableDnd(); + if (config.music) stopMusic(config.music.source); +} + +// ── Paw Animation ── + +const PAW_FRAMES = ["🐾", " 🐾", " 🐾", " 🐾", " 🐾", " 🐾"]; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function pawWalk(label: string): Promise { + for (const frame of PAW_FRAMES) { + process.stdout.write(`\r ${subtle(frame)} ${dim(label)}`); + await sleep(60); + } + process.stdout.write(`\r\x1B[2K`); + console.log(` ${accent("🐾")} ${label}`); +} + +// ── Focus Setup ── + +export async function focusSetupCommand(): Promise { + showMini(); + console.log(""); + + // Auto-detect BEFORE entering clack prompts (execSync can mess with terminal raw mode) + const caps = detectCapabilities(); + + const detected: string[] = []; + if (caps.hasBluetooth) detected.push("Bluetooth"); + if (caps.hasSpotify) detected.push("Spotify"); + if (caps.hasAppleMusic) detected.push("Apple Music"); + if (caps.hasSonos) detected.push("Sonos"); + if (caps.hasHue) detected.push("Hue lights"); + if (caps.hasSlack) detected.push("Slack"); + if (caps.hasObsidian) detected.push("Obsidian"); + if (caps.hasTerminalNotifier) detected.push("Notifications"); + + p.intro(accent("Focus Mode Setup")); + + if (focusConfigExists()) { + const existing = readFocusConfig()!; + p.log.info("You already have a focus config. This will update it."); + printConfig(existing); + console.log(""); + } + + if (detected.length > 0) { + await pawWalk("Sniffing your system..."); + p.log.info(`Detected: ${detected.map((d) => accent(d)).join(", ")}`); + } + + // ── Duration ── + const duration = await p.text({ + message: "How long is your typical focus session? (minutes)", + initialValue: "90", + validate: (v) => { + const n = parseInt(v, 10); + return isNaN(n) || n < 5 || n > 480 ? "Enter 5–480 minutes" : undefined; + }, + }); + if (p.isCancel(duration)) return; + + const config: FocusConfig = { + duration: parseInt(duration as string, 10), + dnd: false, + slackDnd: false, + timer: false, + obsidianLog: false, + }; + + // ── Website Blocking ── + await pawWalk("Distractions..."); + const wantSites = await p.confirm({ + message: "Block distracting websites during focus?", + initialValue: true, + }); + + if (!p.isCancel(wantSites) && wantSites) { + // Step 1: Pick sites + optional custom entry + const selectedSites = await p.multiselect({ + message: "Which sites?", + options: [ + ...COMMON_BLOCKED_SITES.map((s) => ({ value: s, label: s })), + { value: "_custom", label: "Custom...", hint: "type your own" }, + ], + required: false, + }); + + const raw = p.isCancel(selectedSites) ? [] : (selectedSites as string[]); + const hasCustom = raw.includes("_custom"); + const siteList = raw.filter((s) => s !== "_custom"); + + // Step 2: Custom input only if they selected "Custom..." + if (hasCustom) { + const customSites = await p.text({ + message: "Type sites to block (comma-separated)", + defaultValue: "", + }); + if (!p.isCancel(customSites) && (customSites as string).trim()) { + siteList.push(...(customSites as string).split(",").map((s) => s.trim()).filter(Boolean)); + } + } + + // Step 3: Which of those should ask each time? + if (siteList.length > 0) { + const askEach = await p.multiselect({ + message: "Any of these you sometimes need? (they'll ask each session)", + options: siteList.map((s) => ({ value: s, label: s })), + required: false, + }); + + const askList = p.isCancel(askEach) ? [] : (askEach as string[]); + const alwaysList = siteList.filter((s) => !askList.includes(s)); + config.blockedSites = { always: alwaysList, askEachTime: askList }; + } + } + + // ── App Quitting ── + await pawWalk("Apps..."); + const wantApps = await p.confirm({ + message: "Quit distracting apps when focus starts?", + initialValue: true, + }); + + if (!p.isCancel(wantApps) && wantApps) { + const appOptions = [...new Set([...COMMON_QUIT_APPS, ...caps.runningApps.filter((a) => !["Finder", "loginwindow", "SystemUIServer", "Dock", "WindowServer"].includes(a))])]; + + // Step 1: Pick which apps to quit + const selectedApps = await p.multiselect({ + message: "Which apps?", + options: appOptions.slice(0, 15).map((a) => ({ + value: a, + label: a, + hint: caps.runningApps.includes(a) ? "running" : undefined, + })), + required: false, + }); + + const appList = p.isCancel(selectedApps) ? [] : (selectedApps as string[]); + + // Step 2: Which of those should ask each time? + if (appList.length > 0) { + const askEach = await p.multiselect({ + message: "Any of these you sometimes need?", + options: appList.map((a) => ({ value: a, label: a })), + required: false, + }); + + const askList = p.isCancel(askEach) ? [] : (askEach as string[]); + const alwaysList = appList.filter((a) => !askList.includes(a)); + config.quitApps = { always: alwaysList, askEachTime: askList }; + } + } + + // ── Bluetooth ── + if (caps.hasBluetooth && caps.bluetoothDevices.length > 0) { + await pawWalk("Bluetooth..."); + const wantBt = await p.confirm({ + message: "Auto-connect a Bluetooth device (headphones)?", + initialValue: true, + }); + + if (!p.isCancel(wantBt) && wantBt) { + const device = await p.select({ + message: "Which device?", + options: [ + ...caps.bluetoothDevices.map((d) => ({ value: d, label: d })), + { value: "_custom", label: "Type a name..." }, + ], + }); + + if (!p.isCancel(device)) { + let deviceName = device as string; + if (deviceName === "_custom") { + const custom = await p.text({ message: "Device name" }); + if (!p.isCancel(custom)) deviceName = custom as string; + } + if (deviceName !== "_custom") { + config.bluetooth = { device: deviceName }; + } + } + } + } + + // ── Music ── + await pawWalk("Vibes..."); + const musicSources: { value: FocusMusicSource; label: string; hint?: string }[] = []; + if (caps.hasSpotify) musicSources.push({ value: "spotify", label: "Spotify", hint: "spogo" }); + if (caps.hasAppleMusic) musicSources.push({ value: "apple-music", label: "Apple Music" }); + if (caps.hasSonos) musicSources.push({ value: "sonos", label: "Sonos" }); + if (caps.hasYtDlp) musicSources.push({ value: "youtube", label: "YouTube (audio)", hint: "yt-dlp" }); + + const wantMusic = musicSources.length > 0 ? await p.confirm({ + message: "Play music when focus starts?", + initialValue: true, + }) : false; + + if (!p.isCancel(wantMusic) && wantMusic) { + const source = await p.select({ + message: "Music source", + options: musicSources, + }); + + if (!p.isCancel(source)) { + const presets: Record = { + spotify: [ + { value: "lo-fi beats", label: "Lo-fi beats" }, + { value: "deep focus", label: "Deep focus" }, + { value: "white noise", label: "White noise" }, + { value: "nature sounds", label: "Nature sounds" }, + { value: "classical focus", label: "Classical" }, + { value: "_custom", label: "Custom..." }, + ], + "apple-music": [ + { value: "Focus", label: "Focus" }, + { value: "Chill", label: "Chill" }, + { value: "Classical", label: "Classical" }, + { value: "_custom", label: "Custom..." }, + ], + sonos: [ + { value: "_custom", label: "Type a playlist or station..." }, + ], + youtube: [ + { value: "white noise 1 hour", label: "White noise" }, + { value: "nature sounds rain", label: "Rain sounds" }, + { value: "waterfall ambient", label: "Waterfall" }, + { value: "lo-fi hip hop radio", label: "Lo-fi hip hop" }, + { value: "brown noise focus", label: "Brown noise" }, + { value: "_custom", label: "Custom URL..." }, + ], + }; + + const sourcePresets = presets[source as string] ?? [{ value: "_custom", label: "Custom..." }]; + + let query: string | undefined; + + if (sourcePresets.length === 1 && sourcePresets[0].value === "_custom") { + // Only custom option — go straight to text input + const custom = await p.text({ + message: "Playlist or station name", + defaultValue: "", + }); + if (!p.isCancel(custom) && (custom as string).trim()) query = custom as string; + } else { + const picked = await p.select({ + message: "What to play?", + options: sourcePresets, + }); + + if (!p.isCancel(picked)) { + if (picked === "_custom") { + const custom = await p.text({ + message: source === "youtube" ? "YouTube search or URL" : "Playlist or search query", + defaultValue: "", + }); + if (!p.isCancel(custom) && (custom as string).trim()) query = custom as string; + } else { + query = picked as string; + } + } + } + + if (query) { + config.music = { source: source as FocusMusicSource, query }; + } + } + } + + // ── Lights ── + if (caps.hasHue) { + await pawWalk("Lights..."); + const wantLights = await p.confirm({ + message: "Set Hue lights for focus?", + initialValue: true, + }); + + if (!p.isCancel(wantLights) && wantLights) { + let room = "Office"; + if (caps.hueRooms.length > 0) { + const selected = await p.select({ + message: "Which room?", + options: [ + ...caps.hueRooms.map((r) => ({ value: r, label: r })), + { value: "_custom", label: "Type a name..." }, + ], + }); + if (!p.isCancel(selected)) { + room = selected as string; + if (room === "_custom") { + const custom = await p.text({ message: "Room name" }); + if (!p.isCancel(custom)) room = custom as string; + } + } + } + + const brightnessVal = await p.text({ + message: "Brightness (0-100)", + initialValue: "30", + validate: (v) => { + const n = parseInt(v, 10); + return isNaN(n) || n < 0 || n > 100 ? "Enter 0-100" : undefined; + }, + }); + + if (!p.isCancel(brightnessVal)) { + config.lights = { room, brightness: parseInt(brightnessVal as string, 10) }; + + const color = await p.select({ + message: "Light color", + options: [ + { value: "", label: "No preference", hint: "keep current" }, + { value: "warm", label: "Warm", hint: "relaxed, cozy" }, + { value: "cool", label: "Cool", hint: "bright, alert" }, + { value: "red", label: "Red", hint: "low stimulation" }, + { value: "orange", label: "Orange", hint: "sunset vibe" }, + { value: "blue", label: "Blue", hint: "calm focus" }, + ], + }); + if (!p.isCancel(color) && color) { + config.lights.color = color as string; + } + } + } + } + + // ── DND / Slack / Calendar ── + await pawWalk("Finishing up..."); + const toggles = await p.multiselect({ + message: "Enable during focus", + options: [ + { value: "dnd", label: "macOS Do Not Disturb", hint: "silence all notifications" }, + ...(caps.hasSlack ? [{ value: "slackDnd", label: "Slack DND", hint: `auto-set for ${config.duration} min` }] : []), + ...(caps.hasTerminalNotifier ? [{ value: "timer", label: "Timer notification", hint: "notify when session ends" }] : []), + ...(caps.hasObsidian ? [{ value: "obsidianLog", label: "Log to Obsidian", hint: "save focus receipt" }] : []), + ], + required: false, + }); + + if (!p.isCancel(toggles)) { + const selected = toggles as string[]; + config.dnd = selected.includes("dnd"); + config.slackDnd = selected.includes("slackDnd"); + config.timer = selected.includes("timer"); + config.obsidianLog = selected.includes("obsidianLog"); + } + + // ── Skills Directory ── + const defaultDir = getDefaultSkillsDir(); + const installed = listInstalledSkills(defaultDir); + const hint = installed.length > 0 ? `${installed.length} skills installed here` : "recommended"; + + const skillsDir = await p.select({ + message: "Where should the focus skill live?", + options: [ + { value: defaultDir, label: `Global ${dim("~/.claude/skills/")}`, hint }, + { value: ".claude/skills", label: `Project ${dim(".claude/skills/")}` }, + { value: "custom", label: "Custom path" }, + ], + }); + + let targetDir = p.isCancel(skillsDir) ? defaultDir : (skillsDir as string); + if (targetDir === "custom") { + const customDir = await p.text({ + message: "Skills directory path:", + defaultValue: "", + validate: (v) => (v.length === 0 ? "Path cannot be empty" : undefined), + }); + if (p.isCancel(customDir)) { + targetDir = defaultDir; + } else { + targetDir = (customDir as string).replace(/^~/, os.homedir()); + } + } + + // ── Save ── + await pawWalk("Saving your focus config..."); + writeFocusConfig(config); + installFocusSkillMd(targetDir); + + console.log(""); + printConfig(config); + p.outro(accent("Focus Mode configured! ") + dim('Tell Claude to "focus" or "lock in" to start a session. 🐾')); +} + +// ── Install SKILL.md ── + +function installFocusSkillMd(skillsDir: string): void { + const skillDir = path.join(skillsDir, "c-focus"); + fs.mkdirSync(skillDir, { recursive: true }); + + const md = `--- +name: c-focus +description: Focus Mode — orchestrate distraction blocking, environment setup, and session tracking. +tags: [focus, productivity, deep-work, pomodoro, distraction-blocking] +--- + +## What This Skill Does + +You orchestrate focus sessions by reading the user's config and running shell commands directly. + +## Config + +Read \`~/.config/openpaw/focus.json\` for preferences. If missing, suggest: \`openpaw focus setup\` + +\`\`\`json +{ + "duration": 90, + "bluetooth": { "device": "AirPods Pro" }, + "music": { "source": "spotify", "query": "lo-fi beats" }, + "blockedSites": { + "always": ["x.com", "reddit.com"], + "askEachTime": ["youtube.com"] + }, + "quitApps": { + "always": ["Messages", "Mail"], + "askEachTime": ["Discord"] + }, + "lights": { "room": "Office", "brightness": 30, "color": "warm" }, + "dnd": true, + "slackDnd": true, + "timer": true, + "obsidianLog": true +} +\`\`\` + +## Starting a Focus Session + +When the user says "focus", "deep work", "lock in", or similar: + +1. Read \`~/.config/openpaw/focus.json\` +2. Check \`~/.config/openpaw/focus-session.json\` — if it exists, a session is already active +3. If there are \`askEachTime\` sites or apps, ask the user which to include this session +4. Tell the user what you're about to do, then execute each enabled step: + +### Commands to Run (in order) + +**Block websites** (if \`blockedSites\` configured): +\`\`\`bash +# For each site in always + user-approved askEachTime list: +echo "127.0.0.1 site.com # OPENPAW-FOCUS +127.0.0.1 www.site.com # OPENPAW-FOCUS" | sudo tee -a /etc/hosts > /dev/null +sudo dscacheutil -flushcache +sudo killall -HUP mDNSResponder +\`\`\` + +**Quit apps** (if \`quitApps\` configured): +\`\`\`bash +osascript -e 'quit app "AppName"' +\`\`\` + +**Connect bluetooth** (if \`bluetooth\` configured): +\`\`\`bash +blu connect "device name" +\`\`\` + +**Play music** (if \`music\` configured): +\`\`\`bash +# spotify: +spogo search playlist "query" --play +# apple-music: +osascript -e 'tell application "Music" to play playlist "query"' +# sonos: +sonos play "query" +# youtube (yt-dlp — prefix non-URLs with ytsearch1:): +yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "ytsearch1:query" && afplay /tmp/openpaw-focus.mp3 & +\`\`\` + +**Set lights** (if \`lights\` configured): +\`\`\`bash +openhue set room "room" --on --brightness N --color "color" +\`\`\` + +**Enable DND** (if \`dnd: true\`): +\`\`\`bash +defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean true +killall NotificationCenter +\`\`\` + +**Slack DND** (if \`slackDnd: true\`): +\`\`\`bash +slack dnd set +\`\`\` + +**Write the session file** to \`~/.config/openpaw/focus-session.json\`: +\`\`\`json +{ + "startedAt": "", + "endsAt": "", + "config": { ... }, + "blockedSiteAttempts": 0, + "gitCommitsBefore": +} +\`\`\` + +**Start the auto-end timer:** +\`\`\`bash +openpaw focus auto-end & +\`\`\` +This sleeps for the duration, then restores everything and sends a summary via Telegram. + +Or run \`openpaw focus start\` to do all of the above automatically. + +## Ending a Focus Session + +When the user says "stop focus", "end focus", "I'm done", or the timer fires: + +1. **Restore environment:** +\`\`\`bash +# Unblock sites +sudo sed -i '' '/OPENPAW-FOCUS/d' /etc/hosts +sudo dscacheutil -flushcache +# Disable DND +defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean false +killall NotificationCenter +# Stop music (use the matching command for the source) +spogo pause +\`\`\` + +2. **Generate receipt:** +\`\`\`bash +git rev-list --count HEAD # subtract gitCommitsBefore from session file +git diff --stat HEAD~N HEAD +\`\`\` + +3. **Summarize naturally** — how long they focused, commits made, lines added/removed. Be encouraging. +4. **Log to Obsidian** if \`obsidianLog: true\` +5. Delete \`~/.config/openpaw/focus-session.json\` + +## Reconfigure + +\`\`\`bash +openpaw focus setup # Interactive wizard +openpaw focus configure # Alias +\`\`\` + +## Guidelines + +- Only start focus when the user explicitly asks — never suggest unprompted +- Always tell the user what you're doing before each step +- If a command fails (e.g. sudo denied), tell the user and continue with other steps +- Skip any step whose config field is missing or false +- Reference SOUL.md for personal preferences +- When ending, write a human summary — don't just dump numbers +`; + + fs.writeFileSync(path.join(skillDir, "SKILL.md"), md); +} + +// ── Focus Configure (alias to setup) ── + +export async function focusConfigureCommand(): Promise { + return focusSetupCommand(); +} + +// ── Helpers ── + +function printConfig(config: FocusConfig): void { + const lines: string[] = []; + lines.push(`${bold("Duration:")} ${config.duration} min`); + + if (config.blockedSites) { + lines.push(`${bold("Sites:")} ${config.blockedSites.always.length} always blocked, ${config.blockedSites.askEachTime.length} ask-each-time`); + } + if (config.quitApps) { + lines.push(`${bold("Apps:")} ${config.quitApps.always.length} always quit, ${config.quitApps.askEachTime.length} ask-each-time`); + } + if (config.bluetooth) lines.push(`${bold("Bluetooth:")} ${config.bluetooth.device}`); + if (config.music) lines.push(`${bold("Music:")} ${config.music.source} → ${config.music.query}`); + if (config.lights) lines.push(`${bold("Lights:")} ${config.lights.room} at ${config.lights.brightness}%${config.lights.color ? ` (${config.lights.color})` : ""}`); + + const flags: string[] = []; + if (config.dnd) flags.push("DND"); + if (config.slackDnd) flags.push("Slack DND"); + if (config.timer) flags.push("Timer"); + if (config.obsidianLog) flags.push("Obsidian log"); + if (flags.length) lines.push(`${bold("Extras:")} ${flags.join(", ")}`); + + p.note(lines.join("\n"), "Focus Config"); +} diff --git a/src/core/claude-md.ts b/src/core/claude-md.ts index 9d11eb9..b8b5c7d 100644 --- a/src/core/claude-md.ts +++ b/src/core/claude-md.ts @@ -5,6 +5,7 @@ import { skills as catalog } from "../catalog/index.js"; import { listInstalledSkills } from "./skills.js"; import { readConfig as readDashboardConfig } from "./dashboard-server.js"; import { readScheduleConfig } from "./scheduler.js"; +import { readFocusConfig } from "./focus.js"; import type { Skill } from "../types.js"; const START_MARKER = ""; @@ -86,6 +87,19 @@ function generateSection( } } catch {} + // Check for focus mode + try { + const focusConfig = readFocusConfig(); + if (focusConfig) { + lines.push("## Focus Mode"); + lines.push(""); + lines.push(`Focus Mode is configured (${focusConfig.duration} min sessions).`); + lines.push("When the user says \"focus\", \"deep work\", or similar, run `openpaw focus` to start a session."); + lines.push("Run `openpaw focus setup` to reconfigure."); + lines.push(""); + } + } catch {} + lines.push("## How to Use Skills"); lines.push(""); lines.push("- Match user intent to the right skill's CLI tool (check `~/.claude/skills/c-/SKILL.md` for usage)"); diff --git a/src/core/focus.ts b/src/core/focus.ts new file mode 100644 index 0000000..9c9f2a8 --- /dev/null +++ b/src/core/focus.ts @@ -0,0 +1,393 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { execSync } from "node:child_process"; +import type { FocusConfig, FocusSession } from "../types.js"; + +const CONFIG_DIR = path.join(os.homedir(), ".config", "openpaw"); +const FOCUS_PATH = path.join(CONFIG_DIR, "focus.json"); +const SESSION_PATH = path.join(CONFIG_DIR, "focus-session.json"); +const HOSTS_MARKER = "# OPENPAW-FOCUS"; + +function ensureDir(): void { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); +} + +// ── Config I/O ── + +export function readFocusConfig(): FocusConfig | null { + try { + const raw = fs.readFileSync(FOCUS_PATH, "utf-8"); + return JSON.parse(raw) as FocusConfig; + } catch { + return null; + } +} + +export function writeFocusConfig(config: FocusConfig): void { + ensureDir(); + fs.writeFileSync(FOCUS_PATH, JSON.stringify(config, null, 2)); + fs.chmodSync(FOCUS_PATH, 0o600); +} + +export function focusConfigExists(): boolean { + return fs.existsSync(FOCUS_PATH); +} + +// ── Session I/O ── + +export function readFocusSession(): FocusSession | null { + try { + const raw = fs.readFileSync(SESSION_PATH, "utf-8"); + return JSON.parse(raw) as FocusSession; + } catch { + return null; + } +} + +export function writeFocusSession(session: FocusSession): void { + ensureDir(); + fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2)); +} + +export function clearFocusSession(): void { + try { + fs.unlinkSync(SESSION_PATH); + } catch {} +} + +// ── Auto-detection ── + +export interface DetectedCapabilities { + hasBluetooth: boolean; + bluetoothDevices: string[]; + hasSpotify: boolean; + hasAppleMusic: boolean; + hasSonos: boolean; + hasYtDlp: boolean; + hasHue: boolean; + hueRooms: string[]; + hasSlack: boolean; + hasObsidian: boolean; + hasTerminalNotifier: boolean; + hasTelegram: boolean; + runningApps: string[]; +} + +function cmdExists(cmd: string): boolean { + try { + execSync(`command -v ${cmd}`, { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +function tryExec(cmd: string): string { + try { + return execSync(cmd, { stdio: "pipe", timeout: 5000 }).toString().trim(); + } catch { + return ""; + } +} + +export function detectCapabilities(): DetectedCapabilities { + const caps: DetectedCapabilities = { + hasBluetooth: false, + bluetoothDevices: [], + hasSpotify: false, + hasAppleMusic: false, + hasSonos: false, + hasYtDlp: false, + hasHue: false, + hueRooms: [], + hasSlack: false, + hasObsidian: false, + hasTerminalNotifier: false, + hasTelegram: false, + runningApps: [], + }; + + // Bluetooth + if (cmdExists("blu")) { + caps.hasBluetooth = true; + const out = tryExec("blu list --paired 2>/dev/null"); + if (out) { + caps.bluetoothDevices = out + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 10); + } + } else if (cmdExists("blueutil")) { + caps.hasBluetooth = true; + } + + // Music + caps.hasSpotify = cmdExists("spogo"); + caps.hasAppleMusic = process.platform === "darwin"; + caps.hasSonos = cmdExists("sonos"); + caps.hasYtDlp = cmdExists("yt-dlp"); + + // Hue + if (cmdExists("openhue")) { + caps.hasHue = true; + const rooms = tryExec("openhue get rooms --json 2>/dev/null"); + if (rooms) { + try { + const parsed = JSON.parse(rooms); + if (Array.isArray(parsed)) { + caps.hueRooms = parsed.map((r: { metadata?: { name?: string } }) => r.metadata?.name).filter(Boolean) as string[]; + } + } catch {} + } + } + + // Slack + caps.hasSlack = cmdExists("slack"); + + // Obsidian + caps.hasObsidian = cmdExists("obsidian-cli"); + + // Notifications + caps.hasTerminalNotifier = cmdExists("terminal-notifier"); + + // Telegram config + try { + const tgPath = path.join(CONFIG_DIR, "telegram.json"); + caps.hasTelegram = fs.existsSync(tgPath); + } catch {} + + // Running apps (macOS) + if (process.platform === "darwin") { + const out = tryExec( + `osascript -e 'tell application "System Events" to get name of every process whose background only is false' 2>/dev/null`, + ); + if (out) { + caps.runningApps = out.split(", ").filter(Boolean); + } + } + + return caps; +} + +// ── Focus Actions ── + +export function blockSites(sites: string[]): void { + if (sites.length === 0) return; + + const lines = sites.map((s) => `127.0.0.1 ${s} ${HOSTS_MARKER}`); + const wwwLines = sites + .filter((s) => !s.startsWith("www.")) + .map((s) => `127.0.0.1 www.${s} ${HOSTS_MARKER}`); + const allLines = [...lines, ...wwwLines].join("\n"); + + try { + // Append to /etc/hosts (requires sudo — inherit stdio so user can enter password) + execSync(`echo '${allLines}' | sudo tee -a /etc/hosts > /dev/null`, { + stdio: "inherit", + }); + execSync("sudo dscacheutil -flushcache 2>/dev/null; sudo killall -HUP mDNSResponder 2>/dev/null", { + stdio: "pipe", + }); + } catch {} +} + +export function unblockSites(): void { + try { + execSync(`sudo sed -i '' '/${HOSTS_MARKER}/d' /etc/hosts`, { + stdio: "inherit", + }); + execSync("sudo dscacheutil -flushcache 2>/dev/null; sudo killall -HUP mDNSResponder 2>/dev/null", { + stdio: "pipe", + }); + } catch {} +} + +export function quitApps(apps: string[]): void { + for (const app of apps) { + try { + execSync(`osascript -e 'quit app "${app}"' 2>/dev/null`, { + stdio: "pipe", + }); + } catch {} + } +} + +export function enableDnd(): void { + if (process.platform !== "darwin") return; + try { + execSync( + `defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean true && killall NotificationCenter 2>/dev/null`, + { stdio: "pipe" }, + ); + } catch {} +} + +export function disableDnd(): void { + if (process.platform !== "darwin") return; + try { + execSync( + `defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean false && killall NotificationCenter 2>/dev/null`, + { stdio: "pipe" }, + ); + } catch {} +} + +export function enableSlackDnd(minutes: number): void { + try { + execSync(`slack dnd set ${minutes} 2>/dev/null`, { stdio: "pipe" }); + } catch {} +} + +export function setLights(room: string, brightness: number, color?: string): void { + try { + let cmd = `openhue set room "${room}" --on --brightness ${brightness}`; + if (color) cmd += ` --color "${color}"`; + execSync(cmd, { stdio: "pipe" }); + } catch {} +} + +export function connectBluetooth(device: string): void { + try { + if (cmdExists("blu")) { + execSync(`blu connect "${device}" 2>/dev/null`, { stdio: "pipe", timeout: 10000 }); + } + } catch {} +} + +export function startMusic(config: { source: string; query: string }): void { + try { + switch (config.source) { + case "spotify": + execSync(`spogo search playlist "${config.query}" --play 2>/dev/null`, { + stdio: "pipe", + timeout: 10000, + }); + break; + case "apple-music": + execSync( + `osascript -e 'tell application "Music" to play playlist "${config.query}"' 2>/dev/null`, + { stdio: "pipe" }, + ); + break; + case "sonos": + execSync(`sonos play "${config.query}" 2>/dev/null`, { + stdio: "pipe", + timeout: 10000, + }); + break; + case "youtube": { + // If not a URL, use ytsearch to find it + const isUrl = config.query.startsWith("http://") || config.query.startsWith("https://"); + const ytQuery = isUrl ? config.query : `ytsearch1:${config.query}`; + execSync( + `yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "${ytQuery}" 2>/dev/null && afplay /tmp/openpaw-focus.mp3 &`, + { stdio: "pipe", timeout: 30000 }, + ); + break; + } + } + } catch {} +} + +export function stopMusic(source: string): void { + try { + switch (source) { + case "spotify": + execSync("spogo pause 2>/dev/null", { stdio: "pipe" }); + break; + case "apple-music": + execSync(`osascript -e 'tell application "Music" to pause' 2>/dev/null`, { + stdio: "pipe", + }); + break; + case "sonos": + execSync("sonos pause 2>/dev/null", { stdio: "pipe" }); + break; + } + } catch {} +} + +export function getGitCommitCount(): number { + try { + const out = execSync("git rev-list --count HEAD 2>/dev/null", { + stdio: "pipe", + }).toString().trim(); + return parseInt(out, 10) || 0; + } catch { + return 0; + } +} + +export function getGitDiffStats(since: number): { commits: number; linesAdded: number; linesRemoved: number } { + try { + const currentCount = getGitCommitCount(); + const commits = Math.max(0, currentCount - since); + const out = execSync(`git diff --stat HEAD~${commits} HEAD 2>/dev/null`, { + stdio: "pipe", + }).toString(); + const match = out.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/); + return { + commits, + linesAdded: match ? parseInt(match[1], 10) : 0, + linesRemoved: match ? parseInt(match[2], 10) : 0, + }; + } catch { + return { commits: 0, linesAdded: 0, linesRemoved: 0 }; + } +} + +export function sendNotification(title: string, message: string): void { + try { + if (cmdExists("terminal-notifier")) { + execSync( + `terminal-notifier -title "${title}" -message "${message}" -sound default 2>/dev/null`, + { stdio: "pipe" }, + ); + } + } catch {} +} + +export function logToObsidian(duration: number, stats: { commits: number; linesAdded: number; linesRemoved: number }): void { + try { + const date = new Date().toISOString().split("T")[0]; + const time = new Date().toLocaleTimeString(); + const note = `## Focus Session — ${date} ${time}\n- Duration: ${duration} min\n- Commits: ${stats.commits}\n- Lines: +${stats.linesAdded} / -${stats.linesRemoved}\n`; + if (cmdExists("obsidian-cli")) { + execSync( + `obsidian-cli create "Focus Log ${date}" --content "${note.replace(/"/g, '\\"')}" 2>/dev/null`, + { stdio: "pipe" }, + ); + } + } catch {} +} + +// Common distracting sites +export const COMMON_BLOCKED_SITES = [ + "x.com", + "reddit.com", + "instagram.com", + "facebook.com", + "tiktok.com", + "youtube.com", + "linkedin.com", + "threads.net", + "bsky.app", + "discord.com", + "twitch.tv", +]; + +// Common distracting apps +export const COMMON_QUIT_APPS = [ + "Messages", + "Mail", + "Discord", + "Slack", + "Telegram", + "WhatsApp", + "Twitter", + "Safari", + "Chrome", + "Firefox", +]; diff --git a/src/index.ts b/src/index.ts index e9e7820..51b23c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { exportCommand, importCommand } from "./commands/export.js"; import { telegramCommand, telegramSetupCommand } from "./commands/telegram.js"; import { dashboardCommand } from "./commands/dashboard.js"; import { configureCommand } from "./commands/configure.js"; +import { focusCommand, focusSetupCommand, focusConfigureCommand, focusStartCommand, focusEndCommand, focusStatusCommand, focusAutoEndCommand } from "./commands/focus.js"; import { scheduleAddCommand, scheduleListCommand, @@ -104,6 +105,40 @@ program .description("Configure your setup — add skills, change personality, manage dashboard") .action(configureCommand); +// ── Focus ── + +const focus = program + .command("focus") + .description("Start a focus session — block distractions, set the mood, get in the zone"); + +focus.action(focusCommand); + +focus.command("start") + .description("Start a focus session (non-interactive, for Claude Code)") + .option("--all", "Include ask-each-time sites and apps") + .action((opts) => focusStartCommand(opts)); + +focus.command("end") + .description("End the current focus session and show receipt") + .action(() => focusEndCommand()); + +focus.command("status") + .description("Show current focus session status") + .action(() => focusStatusCommand()); + +focus.command("auto-end") + .description("Auto-end focus session (internal — triggered by timer)") + .action(focusAutoEndCommand); + +focus.command("setup") + .description("Set up or reconfigure Focus Mode") + .action(focusSetupCommand); + +focus.command("configure") + .alias("config") + .description("Reconfigure Focus Mode (alias for setup)") + .action(focusConfigureCommand); + const tg = program .command("telegram") .description("Start the Telegram bridge — talk to Claude from your phone"); diff --git a/src/types.ts b/src/types.ts index 62cdda3..e50cb2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,42 @@ export interface DashboardConfig { tasks: DashboardTask[]; } +// ── Focus Mode ── + +export type FocusMusicSource = "spotify" | "apple-music" | "sonos" | "youtube"; + +export interface FocusMusicConfig { + source: FocusMusicSource; + query: string; +} + +export interface FocusConfig { + duration: number; + bluetooth?: { device: string }; + music?: FocusMusicConfig; + blockedSites?: { + always: string[]; + askEachTime: string[]; + }; + quitApps?: { + always: string[]; + askEachTime: string[]; + }; + lights?: { room: string; brightness: number; color?: string }; + dnd: boolean; + slackDnd: boolean; + timer: boolean; + obsidianLog: boolean; +} + +export interface FocusSession { + startedAt: string; + endsAt: string; + config: FocusConfig; + blockedSiteAttempts: number; + gitCommitsBefore: number; +} + export interface SettingsJson { permissions?: { allow?: string[];