From 74819904159702ab9cc586de2f77d0089e6a3e24 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:06:29 +0100 Subject: [PATCH 01/26] Add FocusConfig type and core focus module Adds auto-detection of machine capabilities (bluetooth, Spotify, Hue, Slack, Sonos, etc.), config read/write, website blocking via /etc/hosts, app quitting via osascript, DND, music control across 6 sources, git stats tracking, Obsidian logging, and notification helpers. Co-Authored-By: Claude Opus 4.6 --- src/core/focus.ts | 399 ++++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 38 +++++ 2 files changed, 437 insertions(+) create mode 100644 src/core/focus.ts diff --git a/src/core/focus.ts b/src/core/focus.ts new file mode 100644 index 0000000..a9892fe --- /dev/null +++ b/src/core/focus.ts @@ -0,0 +1,399 @@ +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) + execSync(`echo '${allLines}' | sudo tee -a /etc/hosts > /dev/null`, { + stdio: "pipe", + }); + // Flush DNS cache + 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: "pipe", + }); + 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": + // Play audio via yt-dlp + afplay in background + execSync( + `yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "${config.query}" 2>/dev/null && afplay /tmp/openpaw-focus.mp3 &`, + { stdio: "pipe", timeout: 30000 }, + ); + break; + case "url": + execSync(`open "${config.query}" 2>/dev/null`, { stdio: "pipe" }); + break; + case "local": + execSync(`afplay "${config.query}" &`, { stdio: "pipe" }); + 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 = [ + "twitter.com", + "x.com", + "reddit.com", + "instagram.com", + "facebook.com", + "tiktok.com", + "youtube.com", + "news.ycombinator.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/types.ts b/src/types.ts index 62cdda3..b42b9f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,44 @@ export interface DashboardConfig { tasks: DashboardTask[]; } +// ── Focus Mode ── + +export type FocusMusicSource = "spotify" | "apple-music" | "sonos" | "youtube" | "url" | "local"; + +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; + calendarBlock: boolean; + timer: boolean; + obsidianLog: boolean; + telegramNotify: boolean; +} + +export interface FocusSession { + startedAt: string; + endsAt: string; + config: FocusConfig; + blockedSiteAttempts: number; + gitCommitsBefore: number; +} + export interface SettingsJson { permissions?: { allow?: string[]; From 8ebcd012afeefbca53b0a799442025686c9bfbf6 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:08:04 +0100 Subject: [PATCH 02/26] Add focus command with setup wizard and session management Setup wizard auto-detects machine capabilities (bluetooth devices, Spotify, Hue rooms, Slack, Obsidian, etc.) and only shows relevant questions. Supports website blocking (always vs ask-each-time), app quitting, bluetooth auto-connect, music from 6 sources, Hue lights, DND, Slack DND, calendar blocking, timer notifications, Obsidian logging, and Telegram notifications. Focus start shows receipt with git stats on session end. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 570 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 src/commands/focus.ts diff --git a/src/commands/focus.ts b/src/commands/focus.ts new file mode 100644 index 0000000..3c55e55 --- /dev/null +++ b/src/commands/focus.ts @@ -0,0 +1,570 @@ +import * as p from "@clack/prompts"; +import chalk from "chalk"; +import { showMini, accent, dim, bold } from "../core/branding.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 Start ── + +export async function focusCommand(): Promise { + 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 existing = readFocusSession(); + if (existing) { + const endsAt = new Date(existing.endsAt); + const now = new Date(); + if (endsAt > now) { + const remaining = Math.round((endsAt.getTime() - now.getTime()) / 60000); + p.log.info(`Focus session active — ${bold(remaining + " min")} remaining.`); + const action = await p.select({ + message: "What do you want to do?", + options: [ + { value: "status", label: "Keep going", hint: "close this and get back to work" }, + { value: "end", label: "End session early", hint: "restore everything + show receipt" }, + ], + }); + if (p.isCancel(action) || action === "status") { + p.outro(dim("Stay focused!")); + return; + } + await endFocusSession(config, existing); + return; + } + // Session expired — clean up + await endFocusSession(config, existing); + return; + } + + // Show current config + printConfig(config); + + const confirm = await p.confirm({ + message: `Start ${bold(config.duration + "-minute")} focus session?`, + initialValue: true, + }); + + if (p.isCancel(confirm) || !confirm) { + p.outro(dim("Maybe later.")); + return; + } + + // Ask about "ask each time" items + let sitesToBlock = [...(config.blockedSites?.always ?? [])]; + if (config.blockedSites?.askEachTime?.length) { + const extraSites = await p.multiselect({ + message: "Block these sites too this session?", + options: config.blockedSites.askEachTime.map((s) => ({ + value: s, + label: s, + })), + required: false, + }); + if (!p.isCancel(extraSites)) { + sitesToBlock = [...sitesToBlock, ...(extraSites as string[])]; + } + } + + let appsToQuit = [...(config.quitApps?.always ?? [])]; + if (config.quitApps?.askEachTime?.length) { + const extraApps = await p.multiselect({ + message: "Quit these apps too this session?", + options: config.quitApps.askEachTime.map((a) => ({ + value: a, + label: a, + })), + required: false, + }); + if (!p.isCancel(extraApps)) { + appsToQuit = [...appsToQuit, ...(extraApps as string[])]; + } + } + + // ── Execute focus sequence ── + const s = p.spinner(); + s.start("Entering focus mode..."); + + // 1. Block sites + if (sitesToBlock.length > 0) { + s.message(`Blocking ${sitesToBlock.length} sites...`); + blockSites(sitesToBlock); + } + + // 2. Quit apps + if (appsToQuit.length > 0) { + s.message(`Quitting ${appsToQuit.length} apps...`); + quitApps(appsToQuit); + } + + // 3. Bluetooth + if (config.bluetooth?.device) { + s.message(`Connecting ${config.bluetooth.device}...`); + connectBluetooth(config.bluetooth.device); + } + + // 4. Music + if (config.music) { + s.message(`Starting ${config.music.source} music...`); + startMusic(config.music); + } + + // 5. Lights + if (config.lights) { + s.message(`Setting ${config.lights.room} lights...`); + setLights(config.lights.room, config.lights.brightness, config.lights.color); + } + + // 6. DND + if (config.dnd) { + s.message("Enabling Do Not Disturb..."); + enableDnd(); + } + + // 7. Slack DND + if (config.slackDnd) { + s.message("Setting Slack to DND..."); + enableSlackDnd(config.duration); + } + + s.stop("Focus mode active!"); + + // Save session + const now = new Date(); + const session = { + startedAt: now.toISOString(), + endsAt: new Date(now.getTime() + config.duration * 60000).toISOString(), + config, + blockedSiteAttempts: 0, + gitCommitsBefore: getGitCommitCount(), + }; + writeFocusSession(session); + + // Timer notification + if (config.timer) { + sendNotification("Focus Mode", `${config.duration} minutes starts now. Get after it.`); + } + + console.log(""); + p.log.success(`${bold(config.duration + " minutes")} of focus. Go build something great.`); + p.log.info(`Run ${accent("openpaw focus")} to end early or check status.`); + p.outro(dim("Distractions eliminated.")); +} + +async function endFocusSession(config: FocusConfig, session: { startedAt: string; gitCommitsBefore: number }): Promise { + const s = p.spinner(); + s.start("Restoring environment..."); + + // Unblock sites + if (config.blockedSites && (config.blockedSites.always.length > 0 || config.blockedSites.askEachTime.length > 0)) { + s.message("Unblocking sites..."); + unblockSites(); + } + + // Disable DND + if (config.dnd) { + s.message("Disabling Do Not Disturb..."); + disableDnd(); + } + + // Stop music + if (config.music) { + s.message("Stopping music..."); + stopMusic(config.music.source); + } + + s.stop("Environment restored."); + + // ── Focus Receipt ── + const startTime = new Date(session.startedAt); + const elapsed = Math.round((Date.now() - startTime.getTime()) / 60000); + const stats = getGitDiffStats(session.gitCommitsBefore); + + const receipt: string[] = [ + `${bold("Duration:")} ${elapsed} min`, + `${bold("Commits:")} ${stats.commits}`, + `${bold("Lines:")} ${chalk.green("+" + stats.linesAdded)} / ${chalk.red("-" + stats.linesRemoved)}`, + ]; + + if (config.blockedSites) { + const total = (config.blockedSites.always?.length ?? 0) + (config.blockedSites.askEachTime?.length ?? 0); + receipt.push(`${bold("Sites blocked:")} ${total}`); + } + + console.log(""); + p.note(receipt.join("\n"), "Focus Receipt"); + + // Obsidian log + if (config.obsidianLog) { + logToObsidian(elapsed, stats); + p.log.info(dim("Logged to Obsidian.")); + } + + // Telegram notify + if (config.telegramNotify) { + sendNotification("Focus Complete", `${elapsed} min session done. ${stats.commits} commits, +${stats.linesAdded}/-${stats.linesRemoved} lines.`); + } + + // Timer notification + if (config.timer) { + sendNotification("Focus Session Complete", `${elapsed} minutes of focus. ${stats.commits} commits.`); + } + + clearFocusSession(); + p.outro(dim("Focus session complete. Nice work.")); +} + +// ── Focus Setup ── + +export async function focusSetupCommand(): Promise { + showMini(); + console.log(""); + + 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(""); + } + + // Auto-detect + const spinner = p.spinner(); + spinner.start("Detecting what's on your machine..."); + const caps = detectCapabilities(); + spinner.stop("Detection complete."); + + 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"); + + if (detected.length > 0) { + p.log.info(`Found: ${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, + calendarBlock: false, + timer: false, + obsidianLog: false, + telegramNotify: false, + }; + + // ── Website Blocking ── + const wantSites = await p.confirm({ + message: "Block distracting websites during focus?", + initialValue: true, + }); + + if (!p.isCancel(wantSites) && wantSites) { + const alwaysBlock = await p.multiselect({ + message: "Always block these sites (no questions asked)", + options: COMMON_BLOCKED_SITES.map((s) => ({ + value: s, + label: s, + hint: ["twitter.com", "x.com", "reddit.com", "instagram.com"].includes(s) ? "recommended" : undefined, + })), + required: false, + }); + + const alwaysList = p.isCancel(alwaysBlock) ? [] : (alwaysBlock as string[]); + const remaining = COMMON_BLOCKED_SITES.filter((s) => !alwaysList.includes(s)); + + let askList: string[] = []; + if (remaining.length > 0) { + const askBlock = await p.multiselect({ + message: "Ask about these each session (sometimes you need them)", + options: remaining.map((s) => ({ value: s, label: s })), + required: false, + }); + if (!p.isCancel(askBlock)) askList = askBlock as string[]; + } + + const customSites = await p.text({ + message: "Any other sites to always block? (comma-separated, or skip)", + placeholder: "example.com, another.com", + }); + if (!p.isCancel(customSites) && (customSites as string).trim()) { + const extras = (customSites as string).split(",").map((s) => s.trim()).filter(Boolean); + alwaysList.push(...extras); + } + + config.blockedSites = { always: alwaysList, askEachTime: askList }; + } + + // ── App Quitting ── + const wantApps = await p.confirm({ + message: "Quit distracting apps when focus starts?", + initialValue: true, + }); + + if (!p.isCancel(wantApps) && wantApps) { + // Show detected running apps + common list + const appOptions = [...new Set([...COMMON_QUIT_APPS, ...caps.runningApps.filter((a) => !["Finder", "loginwindow", "SystemUIServer", "Dock", "WindowServer"].includes(a))])]; + + const alwaysQuit = await p.multiselect({ + message: "Always quit these apps", + options: appOptions.slice(0, 15).map((a) => ({ + value: a, + label: a, + hint: caps.runningApps.includes(a) ? "running" : undefined, + })), + required: false, + }); + + const alwaysList = p.isCancel(alwaysQuit) ? [] : (alwaysQuit as string[]); + const remaining = appOptions.filter((a) => !alwaysList.includes(a)); + + let askList: string[] = []; + if (remaining.length > 0) { + const askQuit = await p.multiselect({ + message: "Ask about these each session", + options: remaining.slice(0, 10).map((a) => ({ + value: a, + label: a, + hint: caps.runningApps.includes(a) ? "running" : undefined, + })), + required: false, + }); + if (!p.isCancel(askQuit)) askList = askQuit as string[]; + } + + config.quitApps = { always: alwaysList, askEachTime: askList }; + } + + // ── Bluetooth ── + if (caps.hasBluetooth && caps.bluetoothDevices.length > 0) { + 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 ── + 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" }); + musicSources.push({ value: "url", label: "Open a URL" }); + musicSources.push({ value: "local", label: "Local file (afplay)" }); + + const wantMusic = await p.confirm({ + message: "Play music when focus starts?", + initialValue: caps.hasSpotify || caps.hasAppleMusic, + }); + + if (!p.isCancel(wantMusic) && wantMusic) { + const source = await p.select({ + message: "Music source", + options: musicSources, + }); + + if (!p.isCancel(source)) { + const placeholders: Record = { + spotify: "lo-fi beats", + "apple-music": "Focus", + sonos: "playlist name", + youtube: "https://youtube.com/watch?v=...", + url: "https://...", + local: "/path/to/file.mp3", + }; + + const query = await p.text({ + message: source === "spotify" ? "Playlist or search query" : source === "apple-music" ? "Playlist name" : "URL or path", + placeholder: placeholders[source as string] ?? "", + }); + + if (!p.isCancel(query)) { + config.music = { source: source as FocusMusicSource, query: query as string }; + } + } + } + + // ── Lights ── + if (caps.hasHue) { + 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.text({ + message: "Color? (warm, cool, red, etc — or skip)", + placeholder: "warm", + }); + if (!p.isCancel(color) && (color as string).trim()) { + config.lights.color = (color as string).trim(); + } + } + } + } + + // ── DND / Slack / Calendar ── + 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` }] : []), + { value: "calendarBlock", label: "Calendar block", hint: "create a busy event" }, + { value: "timer", label: "Timer notification", hint: "notify when session ends" }, + ...(caps.hasObsidian ? [{ value: "obsidianLog", label: "Log to Obsidian", hint: "save focus receipt" }] : []), + ...(caps.hasTelegram ? [{ value: "telegramNotify", label: "Telegram notification", hint: "send receipt via Telegram" }] : []), + ], + required: false, + }); + + if (!p.isCancel(toggles)) { + const selected = toggles as string[]; + config.dnd = selected.includes("dnd"); + config.slackDnd = selected.includes("slackDnd"); + config.calendarBlock = selected.includes("calendarBlock"); + config.timer = selected.includes("timer"); + config.obsidianLog = selected.includes("obsidianLog"); + config.telegramNotify = selected.includes("telegramNotify"); + } + + // ── Save ── + writeFocusConfig(config); + + console.log(""); + printConfig(config); + p.outro(accent("Focus Mode configured!") + dim(" Run ") + bold("openpaw focus") + dim(" to start.")); +} + +// ── 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) { + const total = config.blockedSites.always.length + config.blockedSites.askEachTime.length; + 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.calendarBlock) flags.push("Cal block"); + if (config.timer) flags.push("Timer"); + if (config.obsidianLog) flags.push("Obsidian log"); + if (config.telegramNotify) flags.push("Telegram"); + if (flags.length) lines.push(`${bold("Extras:")} ${flags.join(", ")}`); + + p.note(lines.join("\n"), "Focus Config"); +} From fa19e7aff46977a73641ecd679e4bfd4de7cc02c Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:08:40 +0100 Subject: [PATCH 03/26] Wire focus into CLI, configure menu, and skill catalog Adds `openpaw focus` (start), `openpaw focus setup`, and `openpaw focus configure` commands. Adds Focus Mode option to the configure menu. Registers c-focus in the skill catalog. Co-Authored-By: Claude Opus 4.6 --- src/catalog/index.ts | 8 ++++++++ src/commands/configure.ts | 3 ++- src/index.ts | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/catalog/index.ts b/src/catalog/index.ts index b4036a7..66558d0 100644 --- a/src/catalog/index.ts +++ b/src/catalog/index.ts @@ -363,6 +363,14 @@ export const skills: Skill[] = [ platforms: ["darwin", "linux", "win32"], depends: ["email", "calendar"], }, + { + id: "focus", + name: "Focus Mode", + description: "One command to block distractions — sites, apps, DND, music, lights, timer", + category: "automation", + tools: [], + platforms: ["darwin"], + }, // ── Browser & Automation ── { 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/index.ts b/src/index.ts index e9e7820..d5cf185 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 } from "./commands/focus.js"; import { scheduleAddCommand, scheduleListCommand, @@ -104,6 +105,23 @@ 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("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"); From a4e46278a2e8ad34a1853edc47b80e2a91d31eef Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:09:32 +0100 Subject: [PATCH 04/26] Add c-focus SKILL.md and Focus Mode section in CLAUDE.md Creates skills/c-focus/SKILL.md with full usage instructions for Claude. Updates CLAUDE.md generation to include Focus Mode section when configured, so Claude knows to suggest `openpaw focus` when users express intent to concentrate. Co-Authored-By: Claude Opus 4.6 --- skills/c-focus/SKILL.md | 67 +++++++++++++++++++++++++++++++++++++++++ src/core/claude-md.ts | 14 +++++++++ 2 files changed, 81 insertions(+) create mode 100644 skills/c-focus/SKILL.md diff --git a/skills/c-focus/SKILL.md b/skills/c-focus/SKILL.md new file mode 100644 index 0000000..1e1e23a --- /dev/null +++ b/skills/c-focus/SKILL.md @@ -0,0 +1,67 @@ +--- +name: c-focus +description: Focus Mode — one command to block distractions, set the mood, and track your deep work session. Orchestrates website blocking, app quitting, bluetooth, music, lights, DND, Slack, and timer. +tags: [focus, productivity, deep-work, pomodoro, distraction-blocking] +--- + +## What This Skill Does + +Enables Claude to manage focus sessions that orchestrate multiple skills simultaneously — blocking distracting websites, quitting apps, connecting headphones, starting music, dimming lights, enabling Do Not Disturb, and tracking productivity. + +## Commands + +```bash +# Start a focus session (uses saved config) +openpaw focus + +# Set up Focus Mode (auto-detects machine capabilities) +openpaw focus setup + +# Reconfigure Focus Mode +openpaw focus configure +``` + +## How Focus Mode Works + +When the user says "focus", "deep work", "start a focus session", or similar: + +1. **Check config** — Run `openpaw focus` to start a session +2. If not configured, suggest `openpaw focus setup` +3. The command handles everything: site blocking, app quitting, bluetooth, music, lights, DND, Slack DND, timer + +### What Gets Orchestrated + +| Action | How | Requires | +|---|---|---| +| Block websites | `/etc/hosts` + DNS flush | sudo access | +| Quit apps | `osascript -e 'quit app "X"'` | macOS | +| Connect bluetooth | `blu connect "device"` | c-bluetooth | +| Play music | `spogo`, `osascript`, `sonos`, `yt-dlp` | depends on source | +| Set lights | `openhue set room` | c-lights | +| Do Not Disturb | macOS defaults write | macOS | +| Slack DND | `slack dnd set N` | c-slack | +| Timer notification | `terminal-notifier` | c-notify | + +### Focus Receipt + +When a session ends, a receipt shows: +- Duration +- Git commits made during session +- Lines added/removed +- Sites blocked count + +Can optionally log to Obsidian or send via Telegram. + +## Config Location + +`~/.config/openpaw/focus.json` + +Supports "always" vs "ask-each-time" lists for both websites and apps. + +## Usage Guidelines + +- When the user asks to focus or start deep work, run `openpaw focus` +- If they want to change settings, run `openpaw focus setup` +- If they say "stop focus" or "end focus", run `openpaw focus` (it detects active session and offers to end) +- Don't suggest focus mode unprompted — only when the user expresses intent to concentrate +- Reference SOUL.md for the user's preferred focus duration and music preferences 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)"); From 1a708f61796dce0f82e562b8d9263a1745b59c84 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:15:34 +0100 Subject: [PATCH 05/26] Fix focus setup: run detection before clack prompts execSync calls (osascript, etc.) inside detectCapabilities() were corrupting the terminal raw mode state, causing the duration text input to glitch. Moving detection before p.intro() fixes it. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 3c55e55..4c0e9f8 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -251,20 +251,8 @@ export async function focusSetupCommand(): Promise { showMini(); console.log(""); - 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(""); - } - - // Auto-detect - const spinner = p.spinner(); - spinner.start("Detecting what's on your machine..."); + // Auto-detect BEFORE entering clack prompts (execSync can mess with terminal raw mode) const caps = detectCapabilities(); - spinner.stop("Detection complete."); const detected: string[] = []; if (caps.hasBluetooth) detected.push("Bluetooth"); @@ -276,8 +264,17 @@ export async function focusSetupCommand(): Promise { 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) { - p.log.info(`Found: ${detected.map((d) => accent(d)).join(", ")}`); + p.log.info(`Detected: ${detected.map((d) => accent(d)).join(", ")}`); } // ── Duration ── From 42ee3e67d102d31230197a0ed7ec01a21a027536 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:17:38 +0100 Subject: [PATCH 06/26] Remove HN from default blocked sites list Custom sites can still be added during setup via the comma-separated text prompt. Co-Authored-By: Claude Opus 4.6 --- src/core/focus.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/focus.ts b/src/core/focus.ts index a9892fe..c990264 100644 --- a/src/core/focus.ts +++ b/src/core/focus.ts @@ -376,7 +376,6 @@ export const COMMON_BLOCKED_SITES = [ "facebook.com", "tiktok.com", "youtube.com", - "news.ycombinator.com", "linkedin.com", "threads.net", "bsky.app", From dad5747f441d24304609efbaeb97fe478f3f069f Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:21:33 +0100 Subject: [PATCH 07/26] Clean up site/app selection flow in focus setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified to 3 steps: select sites → add custom → mark which ask each time. Removed twitter.com (x.com covers it). Same cleaner flow for app quitting. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 77 ++++++++++++++++++++----------------------- src/core/focus.ts | 1 - 2 files changed, 35 insertions(+), 43 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 4c0e9f8..54c62bb 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -305,39 +305,36 @@ export async function focusSetupCommand(): Promise { }); if (!p.isCancel(wantSites) && wantSites) { - const alwaysBlock = await p.multiselect({ - message: "Always block these sites (no questions asked)", - options: COMMON_BLOCKED_SITES.map((s) => ({ - value: s, - label: s, - hint: ["twitter.com", "x.com", "reddit.com", "instagram.com"].includes(s) ? "recommended" : undefined, - })), + // Step 1: Pick which sites to block + const selectedSites = await p.multiselect({ + message: "Which sites?", + options: COMMON_BLOCKED_SITES.map((s) => ({ value: s, label: s })), required: false, }); - const alwaysList = p.isCancel(alwaysBlock) ? [] : (alwaysBlock as string[]); - const remaining = COMMON_BLOCKED_SITES.filter((s) => !alwaysList.includes(s)); - - let askList: string[] = []; - if (remaining.length > 0) { - const askBlock = await p.multiselect({ - message: "Ask about these each session (sometimes you need them)", - options: remaining.map((s) => ({ value: s, label: s })), - required: false, - }); - if (!p.isCancel(askBlock)) askList = askBlock as string[]; - } + const siteList = p.isCancel(selectedSites) ? [] : (selectedSites as string[]); + // Step 2: Add custom sites const customSites = await p.text({ - message: "Any other sites to always block? (comma-separated, or skip)", - placeholder: "example.com, another.com", + message: "Any others? (comma-separated, or skip)", + placeholder: "news.ycombinator.com, example.com", }); if (!p.isCancel(customSites) && (customSites as string).trim()) { - const extras = (customSites as string).split(",").map((s) => s.trim()).filter(Boolean); - alwaysList.push(...extras); + siteList.push(...(customSites as string).split(",").map((s) => s.trim()).filter(Boolean)); } - config.blockedSites = { always: alwaysList, askEachTime: askList }; + // 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 ── @@ -347,11 +344,11 @@ export async function focusSetupCommand(): Promise { }); if (!p.isCancel(wantApps) && wantApps) { - // Show detected running apps + common list const appOptions = [...new Set([...COMMON_QUIT_APPS, ...caps.runningApps.filter((a) => !["Finder", "loginwindow", "SystemUIServer", "Dock", "WindowServer"].includes(a))])]; - const alwaysQuit = await p.multiselect({ - message: "Always quit these apps", + // 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, @@ -360,24 +357,20 @@ export async function focusSetupCommand(): Promise { required: false, }); - const alwaysList = p.isCancel(alwaysQuit) ? [] : (alwaysQuit as string[]); - const remaining = appOptions.filter((a) => !alwaysList.includes(a)); - - let askList: string[] = []; - if (remaining.length > 0) { - const askQuit = await p.multiselect({ - message: "Ask about these each session", - options: remaining.slice(0, 10).map((a) => ({ - value: a, - label: a, - hint: caps.runningApps.includes(a) ? "running" : undefined, - })), + 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, }); - if (!p.isCancel(askQuit)) askList = askQuit as string[]; - } - config.quitApps = { always: alwaysList, askEachTime: askList }; + const askList = p.isCancel(askEach) ? [] : (askEach as string[]); + const alwaysList = appList.filter((a) => !askList.includes(a)); + config.quitApps = { always: alwaysList, askEachTime: askList }; + } } // ── Bluetooth ── diff --git a/src/core/focus.ts b/src/core/focus.ts index c990264..cdb6326 100644 --- a/src/core/focus.ts +++ b/src/core/focus.ts @@ -369,7 +369,6 @@ export function logToObsidian(duration: number, stats: { commits: number; linesA // Common distracting sites export const COMMON_BLOCKED_SITES = [ - "twitter.com", "x.com", "reddit.com", "instagram.com", From 4ce13769c72ad8e34f73f01a49eddfdf2d31b553 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:24:13 +0100 Subject: [PATCH 08/26] Fix custom sites input submitting placeholder as value clack p.text returns placeholder on empty submit. Switched to defaultValue: "" so hitting enter skips without adding sites. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 54c62bb..fb5d1e1 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -316,8 +316,8 @@ export async function focusSetupCommand(): Promise { // Step 2: Add custom sites const customSites = await p.text({ - message: "Any others? (comma-separated, or skip)", - placeholder: "news.ycombinator.com, example.com", + message: "Any others? (comma-separated, enter to skip)", + defaultValue: "", }); if (!p.isCancel(customSites) && (customSites as string).trim()) { siteList.push(...(customSites as string).split(",").map((s) => s.trim()).filter(Boolean)); From 28d8548f9678ce30e970d2104712b59e3fcfd1e0 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:27:36 +0100 Subject: [PATCH 09/26] Only show music option when a source is detected Removed always-available "Open a URL" and "Local file" fallbacks. Music prompt only appears if Spotify, Apple Music, Sonos, or yt-dlp is detected on the machine. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index fb5d1e1..0fee42d 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -408,13 +408,11 @@ export async function focusSetupCommand(): Promise { 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" }); - musicSources.push({ value: "url", label: "Open a URL" }); - musicSources.push({ value: "local", label: "Local file (afplay)" }); - const wantMusic = await p.confirm({ + const wantMusic = musicSources.length > 0 ? await p.confirm({ message: "Play music when focus starts?", - initialValue: caps.hasSpotify || caps.hasAppleMusic, - }); + initialValue: true, + }) : false; if (!p.isCancel(wantMusic) && wantMusic) { const source = await p.select({ From 2aff84083bd4037dd846a90e415714f8e89244ac Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:45:51 +0100 Subject: [PATCH 10/26] Add Custom option to site picker + music presets per source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Websites: "Custom..." option in the multiselect — text input only appears when selected. Music: preset playlists per source (lo-fi, white noise, nature, rain, waterfall, brown noise, etc.) with a Custom option for typing your own query/URL. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 97 +++++++++++++++++++++++++++++++++---------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 0fee42d..9de0c52 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -305,22 +305,29 @@ export async function focusSetupCommand(): Promise { }); if (!p.isCancel(wantSites) && wantSites) { - // Step 1: Pick which sites to block + // 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 })), + options: [ + ...COMMON_BLOCKED_SITES.map((s) => ({ value: s, label: s })), + { value: "_custom", label: "Custom...", hint: "type your own" }, + ], required: false, }); - const siteList = p.isCancel(selectedSites) ? [] : (selectedSites as string[]); + const raw = p.isCancel(selectedSites) ? [] : (selectedSites as string[]); + const hasCustom = raw.includes("_custom"); + const siteList = raw.filter((s) => s !== "_custom"); - // Step 2: Add custom sites - const customSites = await p.text({ - message: "Any others? (comma-separated, enter to skip)", - defaultValue: "", - }); - if (!p.isCancel(customSites) && (customSites as string).trim()) { - siteList.push(...(customSites as string).split(",").map((s) => s.trim()).filter(Boolean)); + // 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? @@ -421,22 +428,66 @@ export async function focusSetupCommand(): Promise { }); if (!p.isCancel(source)) { - const placeholders: Record = { - spotify: "lo-fi beats", - "apple-music": "Focus", - sonos: "playlist name", - youtube: "https://youtube.com/watch?v=...", - url: "https://...", - local: "/path/to/file.mp3", + 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 query = await p.text({ - message: source === "spotify" ? "Playlist or search query" : source === "apple-music" ? "Playlist name" : "URL or path", - placeholder: placeholders[source as string] ?? "", - }); + 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 (!p.isCancel(query)) { - config.music = { source: source as FocusMusicSource, query: query as string }; + if (query) { + config.music = { source: source as FocusMusicSource, query }; } } } From ce00d3129dfa785dd9e5b1f1fbc6411d1bc0924b Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 00:52:45 +0100 Subject: [PATCH 11/26] Fix YouTube music: use ytsearch for preset queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yt-dlp needs a URL or ytsearch: prefix — plain strings like "white noise 1 hour" would fail. Now auto-prefixes ytsearch1: for non-URL queries so presets actually work. Co-Authored-By: Claude Opus 4.6 --- src/core/focus.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/focus.ts b/src/core/focus.ts index cdb6326..3df1e1c 100644 --- a/src/core/focus.ts +++ b/src/core/focus.ts @@ -278,13 +278,16 @@ export function startMusic(config: { source: string; query: string }): void { timeout: 10000, }); break; - case "youtube": - // Play audio via yt-dlp + afplay in background + 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" "${config.query}" 2>/dev/null && afplay /tmp/openpaw-focus.mp3 &`, + `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; + } case "url": execSync(`open "${config.query}" 2>/dev/null`, { stdio: "pipe" }); break; From 2986917972cf1350f840fe03a5170e496f75ce97 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:06:54 +0100 Subject: [PATCH 12/26] Add background timer that notifies when focus session ends Uses detached spawn to sleep for N minutes then fire terminal-notifier. Survives after the CLI process exits. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 9de0c52..977b6e3 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -1,5 +1,6 @@ import * as p from "@clack/prompts"; import chalk from "chalk"; +import { spawn } from "node:child_process"; import { showMini, accent, dim, bold } from "../core/branding.js"; import { readFocusConfig, @@ -171,9 +172,10 @@ export async function focusCommand(): Promise { }; writeFocusSession(session); - // Timer notification + // Timer: notify at start + schedule end notification in background if (config.timer) { sendNotification("Focus Mode", `${config.duration} minutes starts now. Get after it.`); + scheduleEndNotification(config.duration); } console.log(""); @@ -245,6 +247,17 @@ async function endFocusSession(config: FocusConfig, session: { startedAt: string p.outro(dim("Focus session complete. Nice work.")); } +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 {} +} + // ── Focus Setup ── export async function focusSetupCommand(): Promise { From acdd495163c0a78d3aaebbe12092305b55e4511c Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:13:30 +0100 Subject: [PATCH 13/26] Fix 6 bugs found in focus mode audit - Remove calendarBlock (was never implemented, just a dead toggle) - Remove telegramNotify (was calling local notification, not Telegram) - Fix lights color placeholder submitting "warm" on empty enter - Fix unused total variable in printConfig - Fix sudo site blocking: use stdio inherit so user can enter password - Remove dead url/local music source code paths - Timer option only shown when terminal-notifier is detected Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 22 +++++----------------- src/core/focus.ts | 15 ++++----------- src/types.ts | 4 +--- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 977b6e3..5eaba77 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -233,14 +233,9 @@ async function endFocusSession(config: FocusConfig, session: { startedAt: string p.log.info(dim("Logged to Obsidian.")); } - // Telegram notify - if (config.telegramNotify) { - sendNotification("Focus Complete", `${elapsed} min session done. ${stats.commits} commits, +${stats.linesAdded}/-${stats.linesRemoved} lines.`); - } - - // Timer notification + // End notification (background timer handles the scheduled one, this is for early end) if (config.timer) { - sendNotification("Focus Session Complete", `${elapsed} minutes of focus. ${stats.commits} commits.`); + sendNotification("Focus Complete", `${elapsed} min session. ${stats.commits} commits, +${stats.linesAdded}/-${stats.linesRemoved} lines.`); } clearFocusSession(); @@ -544,8 +539,8 @@ export async function focusSetupCommand(): Promise { config.lights = { room, brightness: parseInt(brightnessVal as string, 10) }; const color = await p.text({ - message: "Color? (warm, cool, red, etc — or skip)", - placeholder: "warm", + message: "Color? (warm, cool, red, etc — enter to skip)", + defaultValue: "", }); if (!p.isCancel(color) && (color as string).trim()) { config.lights.color = (color as string).trim(); @@ -560,10 +555,8 @@ export async function focusSetupCommand(): Promise { 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` }] : []), - { value: "calendarBlock", label: "Calendar block", hint: "create a busy event" }, - { value: "timer", label: "Timer notification", hint: "notify when session ends" }, + ...(caps.hasTerminalNotifier ? [{ value: "timer", label: "Timer notification", hint: "notify when session ends" }] : []), ...(caps.hasObsidian ? [{ value: "obsidianLog", label: "Log to Obsidian", hint: "save focus receipt" }] : []), - ...(caps.hasTelegram ? [{ value: "telegramNotify", label: "Telegram notification", hint: "send receipt via Telegram" }] : []), ], required: false, }); @@ -572,10 +565,8 @@ export async function focusSetupCommand(): Promise { const selected = toggles as string[]; config.dnd = selected.includes("dnd"); config.slackDnd = selected.includes("slackDnd"); - config.calendarBlock = selected.includes("calendarBlock"); config.timer = selected.includes("timer"); config.obsidianLog = selected.includes("obsidianLog"); - config.telegramNotify = selected.includes("telegramNotify"); } // ── Save ── @@ -599,7 +590,6 @@ function printConfig(config: FocusConfig): void { lines.push(`${bold("Duration:")} ${config.duration} min`); if (config.blockedSites) { - const total = config.blockedSites.always.length + config.blockedSites.askEachTime.length; lines.push(`${bold("Sites:")} ${config.blockedSites.always.length} always blocked, ${config.blockedSites.askEachTime.length} ask-each-time`); } if (config.quitApps) { @@ -612,10 +602,8 @@ function printConfig(config: FocusConfig): void { const flags: string[] = []; if (config.dnd) flags.push("DND"); if (config.slackDnd) flags.push("Slack DND"); - if (config.calendarBlock) flags.push("Cal block"); if (config.timer) flags.push("Timer"); if (config.obsidianLog) flags.push("Obsidian log"); - if (config.telegramNotify) flags.push("Telegram"); if (flags.length) lines.push(`${bold("Extras:")} ${flags.join(", ")}`); p.note(lines.join("\n"), "Focus Config"); diff --git a/src/core/focus.ts b/src/core/focus.ts index 3df1e1c..9c9f2a8 100644 --- a/src/core/focus.ts +++ b/src/core/focus.ts @@ -183,11 +183,10 @@ export function blockSites(sites: string[]): void { const allLines = [...lines, ...wwwLines].join("\n"); try { - // Append to /etc/hosts (requires sudo) + // Append to /etc/hosts (requires sudo — inherit stdio so user can enter password) execSync(`echo '${allLines}' | sudo tee -a /etc/hosts > /dev/null`, { - stdio: "pipe", + stdio: "inherit", }); - // Flush DNS cache execSync("sudo dscacheutil -flushcache 2>/dev/null; sudo killall -HUP mDNSResponder 2>/dev/null", { stdio: "pipe", }); @@ -197,7 +196,7 @@ export function blockSites(sites: string[]): void { export function unblockSites(): void { try { execSync(`sudo sed -i '' '/${HOSTS_MARKER}/d' /etc/hosts`, { - stdio: "pipe", + stdio: "inherit", }); execSync("sudo dscacheutil -flushcache 2>/dev/null; sudo killall -HUP mDNSResponder 2>/dev/null", { stdio: "pipe", @@ -288,13 +287,7 @@ export function startMusic(config: { source: string; query: string }): void { ); break; } - case "url": - execSync(`open "${config.query}" 2>/dev/null`, { stdio: "pipe" }); - break; - case "local": - execSync(`afplay "${config.query}" &`, { stdio: "pipe" }); - break; - } + } } catch {} } diff --git a/src/types.ts b/src/types.ts index b42b9f8..e50cb2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,7 +139,7 @@ export interface DashboardConfig { // ── Focus Mode ── -export type FocusMusicSource = "spotify" | "apple-music" | "sonos" | "youtube" | "url" | "local"; +export type FocusMusicSource = "spotify" | "apple-music" | "sonos" | "youtube"; export interface FocusMusicConfig { source: FocusMusicSource; @@ -161,10 +161,8 @@ export interface FocusConfig { lights?: { room: string; brightness: number; color?: string }; dnd: boolean; slackDnd: boolean; - calendarBlock: boolean; timer: boolean; obsidianLog: boolean; - telegramNotify: boolean; } export interface FocusSession { From 3f9dd36a9219903d519e702498e547acb3a97b3d Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:18:10 +0100 Subject: [PATCH 14/26] Add non-interactive focus commands for Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `openpaw focus start [--all]`, `openpaw focus end`, and `openpaw focus status` — plain text output that Claude can read and act on. Claude starts sessions, reads the receipt on end, and summarizes the session naturally. Updates SKILL.md with full instructions for Claude: when to use start vs start --all, how to handle ask-each-time items in conversation, and how to summarize the focus receipt. Co-Authored-By: Claude Opus 4.6 --- skills/c-focus/SKILL.md | 92 +++++++++++--------- src/commands/focus.ts | 183 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 15 +++- 3 files changed, 249 insertions(+), 41 deletions(-) diff --git a/skills/c-focus/SKILL.md b/skills/c-focus/SKILL.md index 1e1e23a..8e7a76f 100644 --- a/skills/c-focus/SKILL.md +++ b/skills/c-focus/SKILL.md @@ -6,62 +6,74 @@ tags: [focus, productivity, deep-work, pomodoro, distraction-blocking] ## What This Skill Does -Enables Claude to manage focus sessions that orchestrate multiple skills simultaneously — blocking distracting websites, quitting apps, connecting headphones, starting music, dimming lights, enabling Do Not Disturb, and tracking productivity. +Manages focus sessions that orchestrate multiple actions — blocking websites, quitting apps, connecting headphones, starting music, dimming lights, enabling DND, and tracking productivity via git stats. -## Commands +## Commands for Claude + +Use these non-interactive commands: ```bash -# Start a focus session (uses saved config) -openpaw focus +# Start a focus session (applies "always" config) +openpaw focus start + +# Start with ALL sites/apps (including "ask-each-time" ones) +openpaw focus start --all -# Set up Focus Mode (auto-detects machine capabilities) -openpaw focus setup +# Check if a session is active +openpaw focus status -# Reconfigure Focus Mode -openpaw focus configure +# End the session — restores environment, prints receipt +openpaw focus end ``` -## How Focus Mode Works +## Interactive Commands (user runs directly) + +```bash +openpaw focus setup # Setup wizard (auto-detects capabilities) +openpaw focus # Interactive start (asks about ask-each-time items) +openpaw focus configure # Reconfigure +``` -When the user says "focus", "deep work", "start a focus session", or similar: +## How to Use This Skill -1. **Check config** — Run `openpaw focus` to start a session -2. If not configured, suggest `openpaw focus setup` -3. The command handles everything: site blocking, app quitting, bluetooth, music, lights, DND, Slack DND, timer +When the user says "focus", "deep work", "lock in", or similar: -### What Gets Orchestrated +1. Run `openpaw focus status` to check current state +2. If no session is active and there are ask-each-time items in config, ask the user if they want to include those too + - Yes → `openpaw focus start --all` + - No → `openpaw focus start` +3. If no ask-each-time items, just run `openpaw focus start` +4. Tell the user what was activated (the command prints this) -| Action | How | Requires | -|---|---|---| -| Block websites | `/etc/hosts` + DNS flush | sudo access | -| Quit apps | `osascript -e 'quit app "X"'` | macOS | -| Connect bluetooth | `blu connect "device"` | c-bluetooth | -| Play music | `spogo`, `osascript`, `sonos`, `yt-dlp` | depends on source | -| Set lights | `openhue set room` | c-lights | -| Do Not Disturb | macOS defaults write | macOS | -| Slack DND | `slack dnd set N` | c-slack | -| Timer notification | `terminal-notifier` | c-notify | +When the user says "stop focus", "end focus", "I'm done": -### Focus Receipt +1. Run `openpaw focus end` +2. Read the receipt and **summarize the session** naturally: + - How long they focused + - Commits made, lines written + - Encourage them based on the output -When a session ends, a receipt shows: -- Duration -- Git commits made during session -- Lines added/removed -- Sites blocked count +If not configured: suggest `openpaw focus setup` -Can optionally log to Obsidian or send via Telegram. +## What Gets Orchestrated -## Config Location +| Action | How | +|---|---| +| Block websites | `/etc/hosts` + DNS flush (sudo) | +| Quit apps | `osascript` | +| Bluetooth | `blu connect` | +| Music | spogo / osascript / sonos / yt-dlp | +| Lights | `openhue set room` | +| Do Not Disturb | macOS defaults | +| Slack DND | `slack dnd set` | +| Timer | background `terminal-notifier` | -`~/.config/openpaw/focus.json` +## Config -Supports "always" vs "ask-each-time" lists for both websites and apps. +`~/.config/openpaw/focus.json` — supports "always" vs "ask-each-time" for sites and apps. -## Usage Guidelines +## Guidelines -- When the user asks to focus or start deep work, run `openpaw focus` -- If they want to change settings, run `openpaw focus setup` -- If they say "stop focus" or "end focus", run `openpaw focus` (it detects active session and offers to end) -- Don't suggest focus mode unprompted — only when the user expresses intent to concentrate -- Reference SOUL.md for the user's preferred focus duration and music preferences +- Don't suggest focus mode unprompted — only when the user expresses intent +- When ending, summarize the receipt naturally, don't just dump numbers +- Reference SOUL.md for the user's focus preferences diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 5eaba77..1e29d5b 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -253,6 +253,189 @@ function scheduleEndNotification(minutes: number): void { } catch {} } +// ── 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); + } + + // 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); +} + // ── Focus Setup ── export async function focusSetupCommand(): Promise { diff --git a/src/index.ts b/src/index.ts index d5cf185..945a72d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +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 } from "./commands/focus.js"; +import { focusCommand, focusSetupCommand, focusConfigureCommand, focusStartCommand, focusEndCommand, focusStatusCommand } from "./commands/focus.js"; import { scheduleAddCommand, scheduleListCommand, @@ -113,6 +113,19 @@ const focus = program 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("setup") .description("Set up or reconfigure Focus Mode") .action(focusSetupCommand); From 3c3ae4a473a395673f0ebe6579fba9896d0e4dff Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:37:53 +0100 Subject: [PATCH 15/26] Auto-end focus session with Claude summary via Telegram When focus starts, a background process sleeps for N minutes then runs `openpaw focus auto-end`. This spawns a Haiku session via the Claude Agent SDK that restores the environment, reads the git stats receipt, writes an encouraging summary, and delivers it via Telegram (or native notification as fallback). Same pattern as the scheduler. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 104 +++++++++++++++++++++++++++++++++++++++++- src/index.ts | 6 ++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 1e29d5b..e82c1b7 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -1,4 +1,5 @@ import * as p from "@clack/prompts"; +import * as path from "node:path"; import chalk from "chalk"; import { spawn } from "node:child_process"; import { showMini, accent, dim, bold } from "../core/branding.js"; @@ -172,12 +173,15 @@ export async function focusCommand(): Promise { }; writeFocusSession(session); - // Timer: notify at start + schedule end notification in background + // Timer notification at start if (config.timer) { sendNotification("Focus Mode", `${config.duration} minutes starts now. Get after it.`); scheduleEndNotification(config.duration); } + // Schedule auto-end: Claude session fires when time is up + scheduleFocusEndSession(config.duration); + console.log(""); p.log.success(`${bold(config.duration + " minutes")} of focus. Go build something great.`); p.log.info(`Run ${accent("openpaw focus")} to end early or check status.`); @@ -253,6 +257,101 @@ function scheduleEndNotification(minutes: number): void { } 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 { @@ -342,6 +441,9 @@ export function focusStartCommand(opts: { all?: boolean }): void { 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}`); diff --git a/src/index.ts b/src/index.ts index 945a72d..51b23c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +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 } from "./commands/focus.js"; +import { focusCommand, focusSetupCommand, focusConfigureCommand, focusStartCommand, focusEndCommand, focusStatusCommand, focusAutoEndCommand } from "./commands/focus.js"; import { scheduleAddCommand, scheduleListCommand, @@ -126,6 +126,10 @@ 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); From c563f562b05f027477eaec19c26be8ff7a066aa7 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:40:28 +0100 Subject: [PATCH 16/26] Rewrite SKILL.md: Claude orchestrates focus directly Instead of wrapping everything in CLI commands, the SKILL.md now gives Claude the exact shell commands to run for each step (block sites, quit apps, connect bluetooth, play music, set lights, DND, Slack). Claude reads the config, asks about ask-each-time items in conversation, executes commands directly, and handles errors naturally. CLI still exists for setup wizard and auto-end timer. Co-Authored-By: Claude Opus 4.6 --- skills/c-focus/SKILL.md | 176 +++++++++++++++++++++++++++++----------- 1 file changed, 129 insertions(+), 47 deletions(-) diff --git a/skills/c-focus/SKILL.md b/skills/c-focus/SKILL.md index 8e7a76f..a243f90 100644 --- a/skills/c-focus/SKILL.md +++ b/skills/c-focus/SKILL.md @@ -1,79 +1,161 @@ --- name: c-focus -description: Focus Mode — one command to block distractions, set the mood, and track your deep work session. Orchestrates website blocking, app quitting, bluetooth, music, lights, DND, Slack, and timer. +description: Focus Mode — orchestrate website blocking, app quitting, bluetooth, music, lights, DND, Slack, and timer based on user's saved preferences. tags: [focus, productivity, deep-work, pomodoro, distraction-blocking] --- ## What This Skill Does -Manages focus sessions that orchestrate multiple actions — blocking websites, quitting apps, connecting headphones, starting music, dimming lights, enabling DND, and tracking productivity via git stats. +You orchestrate focus sessions by reading the user's config and executing shell commands directly. The config is created by `openpaw focus setup` — you don't need the CLI to run a session. -## Commands for Claude +## Config + +Read the user's preferences from `~/.config/openpaw/focus.json`. Example: + +```json +{ + "duration": 90, + "bluetooth": { "device": "AirPods Pro" }, + "music": { "source": "spotify", "query": "lo-fi beats" }, + "blockedSites": { + "always": ["x.com", "reddit.com", "instagram.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`. If missing, suggest: `openpaw focus setup` +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 step: + +### Commands to Run (in order) -Use these non-interactive commands: +**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 -# Start a focus session (applies "always" config) -openpaw focus start +osascript -e 'quit app "Messages"' +osascript -e 'quit app "Mail"' +# etc. +``` -# Start with ALL sites/apps (including "ask-each-time" ones) -openpaw focus start --all +**Connect bluetooth** (if `bluetooth` configured): +```bash +blu connect "AirPods Pro" +``` -# Check if a session is active -openpaw focus status +**Play music** (if `music` configured): +```bash +# Spotify: +spogo search playlist "lo-fi beats" --play +# Apple Music: +osascript -e 'tell application "Music" to play playlist "Focus"' +# Sonos: +sonos play "playlist name" +# YouTube (yt-dlp): +yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "ytsearch1:white noise 1 hour" && afplay /tmp/openpaw-focus.mp3 & +``` -# End the session — restores environment, prints receipt -openpaw focus end +**Set lights** (if `lights` configured): +```bash +openhue set room "Office" --on --brightness 30 --color "warm" ``` -## Interactive Commands (user runs directly) +**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 -openpaw focus setup # Setup wizard (auto-detects capabilities) -openpaw focus # Interactive start (asks about ask-each-time items) -openpaw focus configure # Reconfigure +slack dnd set 90 ``` -## How to Use This Skill +5. Write the session file to `~/.config/openpaw/focus-session.json`: +```json +{ + "startedAt": "2026-03-03T10:00:00.000Z", + "endsAt": "2026-03-03T11:30:00.000Z", + "config": { ... }, + "blockedSiteAttempts": 0, + "gitCommitsBefore": 42 +} +``` +Get `gitCommitsBefore` with: `git rev-list --count HEAD` -When the user says "focus", "deep work", "lock in", or similar: +6. Start the auto-end timer (sends Telegram summary when time is up): +```bash +openpaw focus auto-end & +``` +This sleeps for the duration, then spawns a Claude session to restore everything and send a summary. -1. Run `openpaw focus status` to check current state -2. If no session is active and there are ask-each-time items in config, ask the user if they want to include those too - - Yes → `openpaw focus start --all` - - No → `openpaw focus start` -3. If no ask-each-time items, just run `openpaw focus start` -4. Tell the user what was activated (the command prints this) +Or if you prefer, just run `openpaw focus start` to do steps 4-6 automatically. -When the user says "stop focus", "end focus", "I'm done": +## Ending a Focus Session -1. Run `openpaw focus end` -2. Read the receipt and **summarize the session** naturally: - - How long they focused - - Commits made, lines written - - Encourage them based on the output +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 +spogo pause # or: osascript -e 'tell application "Music" to pause' +``` -If not configured: suggest `openpaw focus setup` +2. **Generate receipt** — read the session file and compute: +```bash +# Commits since session start +git rev-list --count HEAD # subtract gitCommitsBefore +# Lines changed +git diff --stat HEAD~N HEAD +``` -## What Gets Orchestrated +3. **Summarize naturally** — tell the user: + - How long they focused + - Commits made, lines added/removed + - Be encouraging and specific -| Action | How | -|---|---| -| Block websites | `/etc/hosts` + DNS flush (sudo) | -| Quit apps | `osascript` | -| Bluetooth | `blu connect` | -| Music | spogo / osascript / sonos / yt-dlp | -| Lights | `openhue set room` | -| Do Not Disturb | macOS defaults | -| Slack DND | `slack dnd set` | -| Timer | background `terminal-notifier` | +4. **Optional**: log to Obsidian if `obsidianLog: true` +5. Delete `~/.config/openpaw/focus-session.json` -## Config +## Setup (user runs this) -`~/.config/openpaw/focus.json` — supports "always" vs "ask-each-time" for sites and apps. +```bash +openpaw focus setup # Interactive wizard +openpaw focus configure # Alias +``` ## Guidelines -- Don't suggest focus mode unprompted — only when the user expresses intent -- When ending, summarize the receipt naturally, don't just dump numbers -- Reference SOUL.md for the user's focus preferences +- 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 +- Reference SOUL.md for personal preferences +- When ending, write a human summary — don't just dump numbers From 8b77eb69f04c96cc37bf1aa4a67b59250d708cf5 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:48:43 +0100 Subject: [PATCH 17/26] Generate personalized SKILL.md from focus setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user runs `openpaw focus setup`, the wizard now generates a personalized SKILL.md at ~/.claude/skills/c-focus/ with their exact preferences baked in — blocked sites, apps, music commands, bluetooth device, lights, DND, etc. Claude reads one file with everything it needs to orchestrate a focus session. Also removes stale calendarBlock and telegramNotify from config init. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 255 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 253 insertions(+), 2 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index e82c1b7..eaa7e59 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -1,5 +1,7 @@ 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 chalk from "chalk"; import { spawn } from "node:child_process"; import { showMini, accent, dim, bold } from "../core/branding.js"; @@ -585,10 +587,8 @@ export async function focusSetupCommand(): Promise { duration: parseInt(duration as string, 10), dnd: false, slackDnd: false, - calendarBlock: false, timer: false, obsidianLog: false, - telegramNotify: false, }; // ── Website Blocking ── @@ -856,12 +856,263 @@ export async function focusSetupCommand(): Promise { // ── Save ── writeFocusConfig(config); + generateFocusSkillMd(config); console.log(""); printConfig(config); p.outro(accent("Focus Mode configured!") + dim(" Run ") + bold("openpaw focus") + dim(" to start.")); } +// ── Generate Personalized SKILL.md ── + +function generateFocusSkillMd(config: FocusConfig): void { + const skillDir = path.join(os.homedir(), ".claude", "skills", "c-focus"); + fs.mkdirSync(skillDir, { recursive: true }); + + const lines: string[] = []; + + lines.push("---"); + lines.push("name: c-focus"); + lines.push("description: Focus Mode — orchestrate distraction blocking, environment setup, and session tracking based on saved preferences."); + lines.push("tags: [focus, productivity, deep-work, pomodoro, distraction-blocking]"); + lines.push("---"); + lines.push(""); + lines.push("## What This Skill Does"); + lines.push(""); + lines.push("You orchestrate focus sessions by running shell commands directly. The user's preferences are below — read them and execute each enabled step."); + lines.push(""); + + // ── Config summary ── + lines.push("## User Preferences"); + lines.push(""); + lines.push(`- **Default duration:** ${config.duration} minutes`); + + if (config.blockedSites) { + if (config.blockedSites.always.length > 0) { + lines.push(`- **Always block:** ${config.blockedSites.always.join(", ")}`); + } + if (config.blockedSites.askEachTime.length > 0) { + lines.push(`- **Ask each time:** ${config.blockedSites.askEachTime.join(", ")}`); + } + } + + if (config.quitApps) { + if (config.quitApps.always.length > 0) { + lines.push(`- **Always quit:** ${config.quitApps.always.join(", ")}`); + } + if (config.quitApps.askEachTime.length > 0) { + lines.push(`- **Ask to quit:** ${config.quitApps.askEachTime.join(", ")}`); + } + } + + if (config.bluetooth) lines.push(`- **Bluetooth:** connect ${config.bluetooth.device}`); + if (config.music) lines.push(`- **Music:** ${config.music.source} → "${config.music.query}"`); + if (config.lights) { + let lightStr = `- **Lights:** ${config.lights.room} at ${config.lights.brightness}%`; + if (config.lights.color) lightStr += ` (${config.lights.color})`; + lines.push(lightStr); + } + if (config.dnd) lines.push("- **Do Not Disturb:** enabled"); + if (config.slackDnd) lines.push("- **Slack DND:** enabled"); + if (config.timer) lines.push("- **Timer notification:** enabled"); + if (config.obsidianLog) lines.push("- **Obsidian logging:** enabled"); + + // ── Starting a session ── + lines.push(""); + lines.push("## Starting a Focus Session"); + lines.push(""); + lines.push('When the user says "focus", "deep work", "lock in", or similar:'); + lines.push(""); + lines.push("1. Read `~/.config/openpaw/focus.json` — if missing, suggest: `openpaw focus setup`"); + lines.push("2. Check `~/.config/openpaw/focus-session.json` — if it exists, a session is already active"); + + if (config.blockedSites?.askEachTime?.length || config.quitApps?.askEachTime?.length) { + lines.push("3. Ask the user which ask-each-time items to include this session"); + } + + lines.push(`${config.blockedSites?.askEachTime?.length || config.quitApps?.askEachTime?.length ? "4" : "3"}. Tell the user what you're about to do, then execute each step:`); + lines.push(""); + + // ── Commands ── + lines.push("### Commands to Run (in order)"); + lines.push(""); + + if (config.blockedSites && (config.blockedSites.always.length > 0 || config.blockedSites.askEachTime.length > 0)) { + lines.push("**Block websites:**"); + lines.push("```bash"); + lines.push("# For each site in the list:"); + lines.push('echo "127.0.0.1 site.com # OPENPAW-FOCUS'); + lines.push('127.0.0.1 www.site.com # OPENPAW-FOCUS" | sudo tee -a /etc/hosts > /dev/null'); + lines.push("sudo dscacheutil -flushcache"); + lines.push("sudo killall -HUP mDNSResponder"); + lines.push("```"); + lines.push(""); + } + + if (config.quitApps && (config.quitApps.always.length > 0 || config.quitApps.askEachTime.length > 0)) { + lines.push("**Quit apps:**"); + lines.push("```bash"); + for (const app of [...config.quitApps.always, ...config.quitApps.askEachTime].slice(0, 5)) { + lines.push(`osascript -e 'quit app "${app}"'`); + } + if (config.quitApps.always.length + config.quitApps.askEachTime.length > 5) { + lines.push("# ... etc for each app"); + } + lines.push("```"); + lines.push(""); + } + + if (config.bluetooth) { + lines.push("**Connect bluetooth:**"); + lines.push("```bash"); + lines.push(`blu connect "${config.bluetooth.device}"`); + lines.push("```"); + lines.push(""); + } + + if (config.music) { + lines.push("**Play music:**"); + lines.push("```bash"); + switch (config.music.source) { + case "spotify": + lines.push(`spogo search playlist "${config.music.query}" --play`); + break; + case "apple-music": + lines.push(`osascript -e 'tell application "Music" to play playlist "${config.music.query}"'`); + break; + case "sonos": + lines.push(`sonos play "${config.music.query}"`); + break; + case "youtube": { + const isUrl = config.music.query.startsWith("http://") || config.music.query.startsWith("https://"); + const ytQuery = isUrl ? config.music.query : `ytsearch1:${config.music.query}`; + lines.push(`yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "${ytQuery}" && afplay /tmp/openpaw-focus.mp3 &`); + break; + } + } + lines.push("```"); + lines.push(""); + } + + if (config.lights) { + lines.push("**Set lights:**"); + lines.push("```bash"); + let cmd = `openhue set room "${config.lights.room}" --on --brightness ${config.lights.brightness}`; + if (config.lights.color) cmd += ` --color "${config.lights.color}"`; + lines.push(cmd); + lines.push("```"); + lines.push(""); + } + + if (config.dnd) { + lines.push("**Enable DND:**"); + lines.push("```bash"); + lines.push("defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean true"); + lines.push("killall NotificationCenter"); + lines.push("```"); + lines.push(""); + } + + if (config.slackDnd) { + lines.push("**Slack DND:**"); + lines.push("```bash"); + lines.push(`slack dnd set ${config.duration}`); + lines.push("```"); + lines.push(""); + } + + // ── Session file ── + lines.push("**Write the session file** to `~/.config/openpaw/focus-session.json`:"); + lines.push("```json"); + lines.push("{"); + lines.push(' "startedAt": "",'); + lines.push(' "endsAt": "",'); + lines.push(' "config": { ... },'); + lines.push(' "blockedSiteAttempts": 0,'); + lines.push(' "gitCommitsBefore": '); + lines.push("}"); + lines.push("```"); + lines.push(""); + + // ── Auto-end timer ── + lines.push("**Start the auto-end timer:**"); + lines.push("```bash"); + lines.push("openpaw focus auto-end &"); + lines.push("```"); + lines.push("This sleeps for the duration, then spawns a Claude session to restore everything and send a summary."); + lines.push(""); + lines.push("Or run `openpaw focus start` to do all of the above automatically."); + + // ── Ending ── + lines.push(""); + lines.push("## Ending a Focus Session"); + lines.push(""); + lines.push('When the user says "stop focus", "end focus", "I\'m done", or the timer fires:'); + lines.push(""); + lines.push("1. **Restore environment:**"); + lines.push("```bash"); + + if (config.blockedSites) { + lines.push("# Unblock sites"); + lines.push("sudo sed -i '' '/OPENPAW-FOCUS/d' /etc/hosts"); + lines.push("sudo dscacheutil -flushcache"); + } + if (config.dnd) { + lines.push("# Disable DND"); + lines.push("defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean false"); + lines.push("killall NotificationCenter"); + } + if (config.music) { + lines.push("# Stop music"); + switch (config.music.source) { + case "spotify": + lines.push("spogo pause"); + break; + case "apple-music": + lines.push(`osascript -e 'tell application "Music" to pause'`); + break; + case "sonos": + lines.push("sonos pause"); + break; + } + } + + lines.push("```"); + lines.push(""); + lines.push("2. **Generate receipt** — compute:"); + lines.push("```bash"); + lines.push("# Commits since session start"); + lines.push("git rev-list --count HEAD # subtract gitCommitsBefore from session file"); + lines.push("# Lines changed"); + lines.push("git diff --stat HEAD~N HEAD"); + lines.push("```"); + lines.push(""); + lines.push("3. **Summarize naturally** — tell the user:"); + lines.push(" - How long they focused"); + lines.push(" - Commits made, lines added/removed"); + lines.push(" - Be encouraging and specific"); + lines.push(""); + + if (config.obsidianLog) { + lines.push("4. **Log to Obsidian** (enabled)"); + lines.push(""); + } + + lines.push(`${config.obsidianLog ? "5" : "4"}. Delete \`~/.config/openpaw/focus-session.json\``); + + // ── Guidelines ── + lines.push(""); + lines.push("## Guidelines"); + lines.push(""); + lines.push("- Only start focus when the user explicitly asks — never suggest unprompted"); + lines.push("- Always tell the user what you're doing before each step"); + lines.push("- If a command fails (e.g. sudo denied), tell the user and continue with other steps"); + lines.push("- Reference SOUL.md for personal preferences"); + lines.push("- When ending, write a human summary — don't just dump numbers"); + + fs.writeFileSync(path.join(skillDir, "SKILL.md"), lines.join("\n") + "\n"); +} + // ── Focus Configure (alias to setup) ── export async function focusConfigureCommand(): Promise { From 68117b52f1fffb6119ec5bcd454bbfc37b86aaa4 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:52:21 +0100 Subject: [PATCH 18/26] =?UTF-8?q?Remove=20focus=20from=20skill=20catalog?= =?UTF-8?q?=20=E2=80=94=20setup=20handles=20everything?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus Mode doesn't need `openpaw add focus` since it has no external CLI tools to install. `openpaw focus setup` is the single entry point that writes the config JSON and generates the personalized SKILL.md. Co-Authored-By: Claude Opus 4.6 --- src/catalog/index.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/catalog/index.ts b/src/catalog/index.ts index 66558d0..51b7fe0 100644 --- a/src/catalog/index.ts +++ b/src/catalog/index.ts @@ -363,15 +363,6 @@ export const skills: Skill[] = [ platforms: ["darwin", "linux", "win32"], depends: ["email", "calendar"], }, - { - id: "focus", - name: "Focus Mode", - description: "One command to block distractions — sites, apps, DND, music, lights, timer", - category: "automation", - tools: [], - platforms: ["darwin"], - }, - // ── Browser & Automation ── { id: "browser", From 6eb2971b4e1ba8da6be14e51e456445261112acc Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:54:34 +0100 Subject: [PATCH 19/26] Remove JSON read instruction from generated SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences are already baked into the SKILL.md — Claude doesn't need to read the JSON file. The JSON exists for the CLI commands (auto-end, start, end, status), not for Claude. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index eaa7e59..9b62d60 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -923,14 +923,14 @@ function generateFocusSkillMd(config: FocusConfig): void { lines.push(""); lines.push('When the user says "focus", "deep work", "lock in", or similar:'); lines.push(""); - lines.push("1. Read `~/.config/openpaw/focus.json` — if missing, suggest: `openpaw focus setup`"); - lines.push("2. Check `~/.config/openpaw/focus-session.json` — if it exists, a session is already active"); + lines.push("1. Check `~/.config/openpaw/focus-session.json` — if it exists, a session is already active"); - if (config.blockedSites?.askEachTime?.length || config.quitApps?.askEachTime?.length) { - lines.push("3. Ask the user which ask-each-time items to include this session"); + const hasAskEachTime = (config.blockedSites?.askEachTime?.length ?? 0) > 0 || (config.quitApps?.askEachTime?.length ?? 0) > 0; + if (hasAskEachTime) { + lines.push("2. Ask the user which ask-each-time items to include this session"); } - lines.push(`${config.blockedSites?.askEachTime?.length || config.quitApps?.askEachTime?.length ? "4" : "3"}. Tell the user what you're about to do, then execute each step:`); + lines.push(`${hasAskEachTime ? "3" : "2"}. Tell the user what you're about to do, then execute each step:`); lines.push(""); // ── Commands ── From 8fb709b0c6d60e100a86a0999f3a5eb7913c1053 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 01:58:56 +0100 Subject: [PATCH 20/26] Simplify SKILL.md to static template pointing to JSON Single source of truth: the JSON config. SKILL.md is now a static template that tells Claude to read ~/.config/openpaw/focus.json for preferences and shows the commands for each feature. Setup writes both files, configure only touches the JSON. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 404 +++++++++++++++++------------------------- 1 file changed, 159 insertions(+), 245 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 9b62d60..7a8a5c7 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -856,261 +856,175 @@ export async function focusSetupCommand(): Promise { // ── Save ── writeFocusConfig(config); - generateFocusSkillMd(config); + installFocusSkillMd(); console.log(""); printConfig(config); p.outro(accent("Focus Mode configured!") + dim(" Run ") + bold("openpaw focus") + dim(" to start.")); } -// ── Generate Personalized SKILL.md ── +// ── Install SKILL.md ── -function generateFocusSkillMd(config: FocusConfig): void { +function installFocusSkillMd(): void { const skillDir = path.join(os.homedir(), ".claude", "skills", "c-focus"); fs.mkdirSync(skillDir, { recursive: true }); - const lines: string[] = []; - - lines.push("---"); - lines.push("name: c-focus"); - lines.push("description: Focus Mode — orchestrate distraction blocking, environment setup, and session tracking based on saved preferences."); - lines.push("tags: [focus, productivity, deep-work, pomodoro, distraction-blocking]"); - lines.push("---"); - lines.push(""); - lines.push("## What This Skill Does"); - lines.push(""); - lines.push("You orchestrate focus sessions by running shell commands directly. The user's preferences are below — read them and execute each enabled step."); - lines.push(""); - - // ── Config summary ── - lines.push("## User Preferences"); - lines.push(""); - lines.push(`- **Default duration:** ${config.duration} minutes`); - - if (config.blockedSites) { - if (config.blockedSites.always.length > 0) { - lines.push(`- **Always block:** ${config.blockedSites.always.join(", ")}`); - } - if (config.blockedSites.askEachTime.length > 0) { - lines.push(`- **Ask each time:** ${config.blockedSites.askEachTime.join(", ")}`); - } - } - - if (config.quitApps) { - if (config.quitApps.always.length > 0) { - lines.push(`- **Always quit:** ${config.quitApps.always.join(", ")}`); - } - if (config.quitApps.askEachTime.length > 0) { - lines.push(`- **Ask to quit:** ${config.quitApps.askEachTime.join(", ")}`); - } - } - - if (config.bluetooth) lines.push(`- **Bluetooth:** connect ${config.bluetooth.device}`); - if (config.music) lines.push(`- **Music:** ${config.music.source} → "${config.music.query}"`); - if (config.lights) { - let lightStr = `- **Lights:** ${config.lights.room} at ${config.lights.brightness}%`; - if (config.lights.color) lightStr += ` (${config.lights.color})`; - lines.push(lightStr); - } - if (config.dnd) lines.push("- **Do Not Disturb:** enabled"); - if (config.slackDnd) lines.push("- **Slack DND:** enabled"); - if (config.timer) lines.push("- **Timer notification:** enabled"); - if (config.obsidianLog) lines.push("- **Obsidian logging:** enabled"); - - // ── Starting a session ── - lines.push(""); - lines.push("## Starting a Focus Session"); - lines.push(""); - lines.push('When the user says "focus", "deep work", "lock in", or similar:'); - lines.push(""); - lines.push("1. Check `~/.config/openpaw/focus-session.json` — if it exists, a session is already active"); - - const hasAskEachTime = (config.blockedSites?.askEachTime?.length ?? 0) > 0 || (config.quitApps?.askEachTime?.length ?? 0) > 0; - if (hasAskEachTime) { - lines.push("2. Ask the user which ask-each-time items to include this session"); - } - - lines.push(`${hasAskEachTime ? "3" : "2"}. Tell the user what you're about to do, then execute each step:`); - lines.push(""); - - // ── Commands ── - lines.push("### Commands to Run (in order)"); - lines.push(""); - - if (config.blockedSites && (config.blockedSites.always.length > 0 || config.blockedSites.askEachTime.length > 0)) { - lines.push("**Block websites:**"); - lines.push("```bash"); - lines.push("# For each site in the list:"); - lines.push('echo "127.0.0.1 site.com # OPENPAW-FOCUS'); - lines.push('127.0.0.1 www.site.com # OPENPAW-FOCUS" | sudo tee -a /etc/hosts > /dev/null'); - lines.push("sudo dscacheutil -flushcache"); - lines.push("sudo killall -HUP mDNSResponder"); - lines.push("```"); - lines.push(""); - } - - if (config.quitApps && (config.quitApps.always.length > 0 || config.quitApps.askEachTime.length > 0)) { - lines.push("**Quit apps:**"); - lines.push("```bash"); - for (const app of [...config.quitApps.always, ...config.quitApps.askEachTime].slice(0, 5)) { - lines.push(`osascript -e 'quit app "${app}"'`); - } - if (config.quitApps.always.length + config.quitApps.askEachTime.length > 5) { - lines.push("# ... etc for each app"); - } - lines.push("```"); - lines.push(""); - } - - if (config.bluetooth) { - lines.push("**Connect bluetooth:**"); - lines.push("```bash"); - lines.push(`blu connect "${config.bluetooth.device}"`); - lines.push("```"); - lines.push(""); - } - - if (config.music) { - lines.push("**Play music:**"); - lines.push("```bash"); - switch (config.music.source) { - case "spotify": - lines.push(`spogo search playlist "${config.music.query}" --play`); - break; - case "apple-music": - lines.push(`osascript -e 'tell application "Music" to play playlist "${config.music.query}"'`); - break; - case "sonos": - lines.push(`sonos play "${config.music.query}"`); - break; - case "youtube": { - const isUrl = config.music.query.startsWith("http://") || config.music.query.startsWith("https://"); - const ytQuery = isUrl ? config.music.query : `ytsearch1:${config.music.query}`; - lines.push(`yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "${ytQuery}" && afplay /tmp/openpaw-focus.mp3 &`); - break; - } - } - lines.push("```"); - lines.push(""); - } - - if (config.lights) { - lines.push("**Set lights:**"); - lines.push("```bash"); - let cmd = `openhue set room "${config.lights.room}" --on --brightness ${config.lights.brightness}`; - if (config.lights.color) cmd += ` --color "${config.lights.color}"`; - lines.push(cmd); - lines.push("```"); - lines.push(""); - } - - if (config.dnd) { - lines.push("**Enable DND:**"); - lines.push("```bash"); - lines.push("defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean true"); - lines.push("killall NotificationCenter"); - lines.push("```"); - lines.push(""); - } - - if (config.slackDnd) { - lines.push("**Slack DND:**"); - lines.push("```bash"); - lines.push(`slack dnd set ${config.duration}`); - lines.push("```"); - lines.push(""); - } - - // ── Session file ── - lines.push("**Write the session file** to `~/.config/openpaw/focus-session.json`:"); - lines.push("```json"); - lines.push("{"); - lines.push(' "startedAt": "",'); - lines.push(' "endsAt": "",'); - lines.push(' "config": { ... },'); - lines.push(' "blockedSiteAttempts": 0,'); - lines.push(' "gitCommitsBefore": '); - lines.push("}"); - lines.push("```"); - lines.push(""); - - // ── Auto-end timer ── - lines.push("**Start the auto-end timer:**"); - lines.push("```bash"); - lines.push("openpaw focus auto-end &"); - lines.push("```"); - lines.push("This sleeps for the duration, then spawns a Claude session to restore everything and send a summary."); - lines.push(""); - lines.push("Or run `openpaw focus start` to do all of the above automatically."); - - // ── Ending ── - lines.push(""); - lines.push("## Ending a Focus Session"); - lines.push(""); - lines.push('When the user says "stop focus", "end focus", "I\'m done", or the timer fires:'); - lines.push(""); - lines.push("1. **Restore environment:**"); - lines.push("```bash"); - - if (config.blockedSites) { - lines.push("# Unblock sites"); - lines.push("sudo sed -i '' '/OPENPAW-FOCUS/d' /etc/hosts"); - lines.push("sudo dscacheutil -flushcache"); - } - if (config.dnd) { - lines.push("# Disable DND"); - lines.push("defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean false"); - lines.push("killall NotificationCenter"); - } - if (config.music) { - lines.push("# Stop music"); - switch (config.music.source) { - case "spotify": - lines.push("spogo pause"); - break; - case "apple-music": - lines.push(`osascript -e 'tell application "Music" to pause'`); - break; - case "sonos": - lines.push("sonos pause"); - break; - } - } - - lines.push("```"); - lines.push(""); - lines.push("2. **Generate receipt** — compute:"); - lines.push("```bash"); - lines.push("# Commits since session start"); - lines.push("git rev-list --count HEAD # subtract gitCommitsBefore from session file"); - lines.push("# Lines changed"); - lines.push("git diff --stat HEAD~N HEAD"); - lines.push("```"); - lines.push(""); - lines.push("3. **Summarize naturally** — tell the user:"); - lines.push(" - How long they focused"); - lines.push(" - Commits made, lines added/removed"); - lines.push(" - Be encouraging and specific"); - lines.push(""); - - if (config.obsidianLog) { - lines.push("4. **Log to Obsidian** (enabled)"); - lines.push(""); - } - - lines.push(`${config.obsidianLog ? "5" : "4"}. Delete \`~/.config/openpaw/focus-session.json\``); - - // ── Guidelines ── - lines.push(""); - lines.push("## Guidelines"); - lines.push(""); - lines.push("- Only start focus when the user explicitly asks — never suggest unprompted"); - lines.push("- Always tell the user what you're doing before each step"); - lines.push("- If a command fails (e.g. sudo denied), tell the user and continue with other steps"); - lines.push("- Reference SOUL.md for personal preferences"); - lines.push("- When ending, write a human summary — don't just dump numbers"); - - fs.writeFileSync(path.join(skillDir, "SKILL.md"), lines.join("\n") + "\n"); + 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) ── From 9edccaa66ddd860e52cf2c916c107371ba6738fe Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 02:14:08 +0100 Subject: [PATCH 21/26] Make openpaw focus show config instead of starting a session Claude orchestrates focus sessions, not the CLI. Now `openpaw focus` shows your saved preferences and active session status. Tells you to ask Claude to "focus" or "lock in" to start. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 215 +++++------------------------------------- 1 file changed, 21 insertions(+), 194 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 7a8a5c7..713a287 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -2,7 +2,6 @@ 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 chalk from "chalk"; import { spawn } from "node:child_process"; import { showMini, accent, dim, bold } from "../core/branding.js"; import { @@ -32,9 +31,9 @@ import { } from "../core/focus.js"; import type { FocusConfig, FocusMusicSource } from "../types.js"; -// ── Focus Start ── +// ── Focus (show config + status) ── -export async function focusCommand(): Promise { +export function focusCommand(): void { showMini(); console.log(""); @@ -46,206 +45,34 @@ export async function focusCommand(): Promise { } // Check for active session - const existing = readFocusSession(); - if (existing) { - const endsAt = new Date(existing.endsAt); + 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); - p.log.info(`Focus session active — ${bold(remaining + " min")} remaining.`); - const action = await p.select({ - message: "What do you want to do?", - options: [ - { value: "status", label: "Keep going", hint: "close this and get back to work" }, - { value: "end", label: "End session early", hint: "restore everything + show receipt" }, - ], - }); - if (p.isCancel(action) || action === "status") { - p.outro(dim("Stay focused!")); - return; - } - await endFocusSession(config, existing); + 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 — clean up - await endFocusSession(config, existing); - return; + // Session expired + p.log.warn("Previous session expired. Run " + bold("openpaw focus end") + " to clean up."); } - // Show current config + // Show config printConfig(config); - - const confirm = await p.confirm({ - message: `Start ${bold(config.duration + "-minute")} focus session?`, - initialValue: true, - }); - - if (p.isCancel(confirm) || !confirm) { - p.outro(dim("Maybe later.")); - return; - } - - // Ask about "ask each time" items - let sitesToBlock = [...(config.blockedSites?.always ?? [])]; - if (config.blockedSites?.askEachTime?.length) { - const extraSites = await p.multiselect({ - message: "Block these sites too this session?", - options: config.blockedSites.askEachTime.map((s) => ({ - value: s, - label: s, - })), - required: false, - }); - if (!p.isCancel(extraSites)) { - sitesToBlock = [...sitesToBlock, ...(extraSites as string[])]; - } - } - - let appsToQuit = [...(config.quitApps?.always ?? [])]; - if (config.quitApps?.askEachTime?.length) { - const extraApps = await p.multiselect({ - message: "Quit these apps too this session?", - options: config.quitApps.askEachTime.map((a) => ({ - value: a, - label: a, - })), - required: false, - }); - if (!p.isCancel(extraApps)) { - appsToQuit = [...appsToQuit, ...(extraApps as string[])]; - } - } - - // ── Execute focus sequence ── - const s = p.spinner(); - s.start("Entering focus mode..."); - - // 1. Block sites - if (sitesToBlock.length > 0) { - s.message(`Blocking ${sitesToBlock.length} sites...`); - blockSites(sitesToBlock); - } - - // 2. Quit apps - if (appsToQuit.length > 0) { - s.message(`Quitting ${appsToQuit.length} apps...`); - quitApps(appsToQuit); - } - - // 3. Bluetooth - if (config.bluetooth?.device) { - s.message(`Connecting ${config.bluetooth.device}...`); - connectBluetooth(config.bluetooth.device); - } - - // 4. Music - if (config.music) { - s.message(`Starting ${config.music.source} music...`); - startMusic(config.music); - } - - // 5. Lights - if (config.lights) { - s.message(`Setting ${config.lights.room} lights...`); - setLights(config.lights.room, config.lights.brightness, config.lights.color); - } - - // 6. DND - if (config.dnd) { - s.message("Enabling Do Not Disturb..."); - enableDnd(); - } - - // 7. Slack DND - if (config.slackDnd) { - s.message("Setting Slack to DND..."); - enableSlackDnd(config.duration); - } - - s.stop("Focus mode active!"); - - // Save session - const now = new Date(); - const session = { - startedAt: now.toISOString(), - endsAt: new Date(now.getTime() + config.duration * 60000).toISOString(), - config, - blockedSiteAttempts: 0, - gitCommitsBefore: getGitCommitCount(), - }; - writeFocusSession(session); - - // Timer notification at start - if (config.timer) { - sendNotification("Focus Mode", `${config.duration} minutes starts now. Get after it.`); - scheduleEndNotification(config.duration); - } - - // Schedule auto-end: Claude session fires when time is up - scheduleFocusEndSession(config.duration); - console.log(""); - p.log.success(`${bold(config.duration + " minutes")} of focus. Go build something great.`); - p.log.info(`Run ${accent("openpaw focus")} to end early or check status.`); - p.outro(dim("Distractions eliminated.")); -} - -async function endFocusSession(config: FocusConfig, session: { startedAt: string; gitCommitsBefore: number }): Promise { - const s = p.spinner(); - s.start("Restoring environment..."); - - // Unblock sites - if (config.blockedSites && (config.blockedSites.always.length > 0 || config.blockedSites.askEachTime.length > 0)) { - s.message("Unblocking sites..."); - unblockSites(); - } - - // Disable DND - if (config.dnd) { - s.message("Disabling Do Not Disturb..."); - disableDnd(); - } - - // Stop music - if (config.music) { - s.message("Stopping music..."); - stopMusic(config.music.source); - } - - s.stop("Environment restored."); - - // ── Focus Receipt ── - const startTime = new Date(session.startedAt); - const elapsed = Math.round((Date.now() - startTime.getTime()) / 60000); - const stats = getGitDiffStats(session.gitCommitsBefore); - - const receipt: string[] = [ - `${bold("Duration:")} ${elapsed} min`, - `${bold("Commits:")} ${stats.commits}`, - `${bold("Lines:")} ${chalk.green("+" + stats.linesAdded)} / ${chalk.red("-" + stats.linesRemoved)}`, - ]; - - if (config.blockedSites) { - const total = (config.blockedSites.always?.length ?? 0) + (config.blockedSites.askEachTime?.length ?? 0); - receipt.push(`${bold("Sites blocked:")} ${total}`); - } - - console.log(""); - p.note(receipt.join("\n"), "Focus Receipt"); - - // Obsidian log - if (config.obsidianLog) { - logToObsidian(elapsed, stats); - p.log.info(dim("Logged to Obsidian.")); - } - - // End notification (background timer handles the scheduled one, this is for early end) - if (config.timer) { - sendNotification("Focus Complete", `${elapsed} min session. ${stats.commits} commits, +${stats.linesAdded}/-${stats.linesRemoved} lines.`); - } - - clearFocusSession(); - p.outro(dim("Focus session complete. Nice work.")); + 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 { From ac7ed86fcf599aa4620afc8892991c570bad1708 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 02:16:37 +0100 Subject: [PATCH 22/26] Sync repo SKILL.md template with installed version Co-Authored-By: Claude Opus 4.6 --- skills/c-focus/SKILL.md | 74 ++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/skills/c-focus/SKILL.md b/skills/c-focus/SKILL.md index a243f90..5d85252 100644 --- a/skills/c-focus/SKILL.md +++ b/skills/c-focus/SKILL.md @@ -1,16 +1,16 @@ --- name: c-focus -description: Focus Mode — orchestrate website blocking, app quitting, bluetooth, music, lights, DND, Slack, and timer based on user's saved preferences. +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 executing shell commands directly. The config is created by `openpaw focus setup` — you don't need the CLI to run a session. +You orchestrate focus sessions by reading the user's config and running shell commands directly. ## Config -Read the user's preferences from `~/.config/openpaw/focus.json`. Example: +Read `~/.config/openpaw/focus.json` for preferences. If missing, suggest: `openpaw focus setup` ```json { @@ -18,7 +18,7 @@ Read the user's preferences from `~/.config/openpaw/focus.json`. Example: "bluetooth": { "device": "AirPods Pro" }, "music": { "source": "spotify", "query": "lo-fi beats" }, "blockedSites": { - "always": ["x.com", "reddit.com", "instagram.com"], + "always": ["x.com", "reddit.com"], "askEachTime": ["youtube.com"] }, "quitApps": { @@ -37,10 +37,10 @@ Read the user's preferences from `~/.config/openpaw/focus.json`. Example: When the user says "focus", "deep work", "lock in", or similar: -1. Read `~/.config/openpaw/focus.json`. If missing, suggest: `openpaw focus setup` +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 step: +4. Tell the user what you're about to do, then execute each enabled step: ### Commands to Run (in order) @@ -55,31 +55,29 @@ sudo killall -HUP mDNSResponder **Quit apps** (if `quitApps` configured): ```bash -osascript -e 'quit app "Messages"' -osascript -e 'quit app "Mail"' -# etc. +osascript -e 'quit app "AppName"' ``` **Connect bluetooth** (if `bluetooth` configured): ```bash -blu connect "AirPods Pro" +blu connect "device name" ``` **Play music** (if `music` configured): ```bash -# Spotify: -spogo search playlist "lo-fi beats" --play -# Apple Music: -osascript -e 'tell application "Music" to play playlist "Focus"' -# Sonos: -sonos play "playlist name" -# YouTube (yt-dlp): -yt-dlp -x --audio-format mp3 -o "/tmp/openpaw-focus.%(ext)s" "ytsearch1:white noise 1 hour" && afplay /tmp/openpaw-focus.mp3 & +# 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 "Office" --on --brightness 30 --color "warm" +openhue set room "room" --on --brightness N --color "color" ``` **Enable DND** (if `dnd: true`): @@ -90,28 +88,27 @@ killall NotificationCenter **Slack DND** (if `slackDnd: true`): ```bash -slack dnd set 90 +slack dnd set ``` -5. Write the session file to `~/.config/openpaw/focus-session.json`: +**Write the session file** to `~/.config/openpaw/focus-session.json`: ```json { - "startedAt": "2026-03-03T10:00:00.000Z", - "endsAt": "2026-03-03T11:30:00.000Z", + "startedAt": "", + "endsAt": "", "config": { ... }, "blockedSiteAttempts": 0, - "gitCommitsBefore": 42 + "gitCommitsBefore": } ``` -Get `gitCommitsBefore` with: `git rev-list --count HEAD` -6. Start the auto-end timer (sends Telegram summary when time is up): +**Start the auto-end timer:** ```bash openpaw focus auto-end & ``` -This sleeps for the duration, then spawns a Claude session to restore everything and send a summary. +This sleeps for the duration, then restores everything and sends a summary via Telegram. -Or if you prefer, just run `openpaw focus start` to do steps 4-6 automatically. +Or run `openpaw focus start` to do all of the above automatically. ## Ending a Focus Session @@ -125,27 +122,21 @@ sudo dscacheutil -flushcache # Disable DND defaults -currentHost write ~/Library/Preferences/ByHost/com.apple.notificationcenterui doNotDisturb -boolean false killall NotificationCenter -# Stop music -spogo pause # or: osascript -e 'tell application "Music" to pause' +# Stop music (use the matching command for the source) +spogo pause ``` -2. **Generate receipt** — read the session file and compute: +2. **Generate receipt:** ```bash -# Commits since session start -git rev-list --count HEAD # subtract gitCommitsBefore -# Lines changed +git rev-list --count HEAD # subtract gitCommitsBefore from session file git diff --stat HEAD~N HEAD ``` -3. **Summarize naturally** — tell the user: - - How long they focused - - Commits made, lines added/removed - - Be encouraging and specific - -4. **Optional**: log to Obsidian if `obsidianLog: true` +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` -## Setup (user runs this) +## Reconfigure ```bash openpaw focus setup # Interactive wizard @@ -157,5 +148,6 @@ openpaw focus configure # Alias - 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 From 0a5b9d272636040b78c1525c5b92fc13023bb37c Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 02:17:58 +0100 Subject: [PATCH 23/26] Use select options for light color instead of text input Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 713a287..88d2ba2 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -650,12 +650,19 @@ export async function focusSetupCommand(): Promise { if (!p.isCancel(brightnessVal)) { config.lights = { room, brightness: parseInt(brightnessVal as string, 10) }; - const color = await p.text({ - message: "Color? (warm, cool, red, etc — enter to skip)", - defaultValue: "", + 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 as string).trim()) { - config.lights.color = (color as string).trim(); + if (!p.isCancel(color) && color) { + config.lights.color = color as string; } } } From 810565640926e6cf4cfc83d4a36f1ef610de0288 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 02:20:00 +0100 Subject: [PATCH 24/26] Ask where to install focus skill in setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the main wizard pattern — global ~/.claude/skills/ (with hint showing how many skills are already there), project .claude/skills/, or custom path. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 88d2ba2..4021944 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import * as os from "node:os"; import { spawn } from "node:child_process"; import { showMini, accent, dim, bold } from "../core/branding.js"; +import { getDefaultSkillsDir, listInstalledSkills } from "../core/skills.js"; import { readFocusConfig, writeFocusConfig, @@ -688,9 +689,37 @@ export async function focusSetupCommand(): Promise { 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 ── writeFocusConfig(config); - installFocusSkillMd(); + installFocusSkillMd(targetDir); console.log(""); printConfig(config); @@ -699,8 +728,8 @@ export async function focusSetupCommand(): Promise { // ── Install SKILL.md ── -function installFocusSkillMd(): void { - const skillDir = path.join(os.homedir(), ".claude", "skills", "c-focus"); +function installFocusSkillMd(skillsDir: string): void { + const skillDir = path.join(skillsDir, "c-focus"); fs.mkdirSync(skillDir, { recursive: true }); const md = `--- From 497276a64324e8355122a4f7d88d5e01cd085bfb Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 02:22:33 +0100 Subject: [PATCH 25/26] Update setup outro to point to Claude instead of CLI Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 4021944..1a204a4 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -723,7 +723,7 @@ export async function focusSetupCommand(): Promise { console.log(""); printConfig(config); - p.outro(accent("Focus Mode configured!") + dim(" Run ") + bold("openpaw focus") + dim(" to start.")); + p.outro(accent("Focus Mode configured! ") + dim('Tell Claude to "focus" or "lock in" to start a session. 🐾')); } // ── Install SKILL.md ── From bcda1dcdd13c4fdff70622d0466765d04dbd91df Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 3 Mar 2026 02:25:59 +0100 Subject: [PATCH 26/26] Add paw walk animation between focus setup wizard steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Little 🐾 walks across the terminal between each section — sniffing system, distractions, apps, bluetooth, vibes, lights, finishing up, and saving config. Co-Authored-By: Claude Opus 4.6 --- src/commands/focus.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/commands/focus.ts b/src/commands/focus.ts index 1a204a4..2b0c6c5 100644 --- a/src/commands/focus.ts +++ b/src/commands/focus.ts @@ -3,7 +3,7 @@ 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 } from "../core/branding.js"; +import { showMini, accent, dim, bold, subtle } from "../core/branding.js"; import { getDefaultSkillsDir, listInstalledSkills } from "../core/skills.js"; import { readFocusConfig, @@ -368,6 +368,23 @@ function restoreEnvironment(config: FocusConfig): void { 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 { @@ -397,6 +414,7 @@ export async function focusSetupCommand(): Promise { } if (detected.length > 0) { + await pawWalk("Sniffing your system..."); p.log.info(`Detected: ${detected.map((d) => accent(d)).join(", ")}`); } @@ -420,6 +438,7 @@ export async function focusSetupCommand(): Promise { }; // ── Website Blocking ── + await pawWalk("Distractions..."); const wantSites = await p.confirm({ message: "Block distracting websites during focus?", initialValue: true, @@ -466,6 +485,7 @@ export async function focusSetupCommand(): Promise { } // ── App Quitting ── + await pawWalk("Apps..."); const wantApps = await p.confirm({ message: "Quit distracting apps when focus starts?", initialValue: true, @@ -503,6 +523,7 @@ export async function focusSetupCommand(): Promise { // ── 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, @@ -531,6 +552,7 @@ export async function focusSetupCommand(): Promise { } // ── 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" }); @@ -615,6 +637,7 @@ export async function focusSetupCommand(): Promise { // ── Lights ── if (caps.hasHue) { + await pawWalk("Lights..."); const wantLights = await p.confirm({ message: "Set Hue lights for focus?", initialValue: true, @@ -670,6 +693,7 @@ export async function focusSetupCommand(): Promise { } // ── DND / Slack / Calendar ── + await pawWalk("Finishing up..."); const toggles = await p.multiselect({ message: "Enable during focus", options: [ @@ -718,6 +742,7 @@ export async function focusSetupCommand(): Promise { } // ── Save ── + await pawWalk("Saving your focus config..."); writeFocusConfig(config); installFocusSkillMd(targetDir);