// Name: Flashcards// Description: Spaced-repeat images, managed just by filesystem// Shortcut: shift+ctrl+option+cmd+oimport '@johnlindquist/kit'const flashcardsDir = kenvPath('db', 'flashcards')await ensureDir(flashcardsDir)const today = new Date()let files = await readdir(flashcardsDir)let renamed = 0for (const file of files) {if (!file.endsWith('.png')) continueconst parsed = parseFilename(file)if (!parsed) {renamed++const max = currentMaxForDate(formatDate(today))const newName = formatFilename(formatDate(today), max + 1, 0)await rename(path.join(flashcardsDir, file), path.join(flashcardsDir, newName))}}if (renamed > 0) {notify(`Flashcards: Renamed ${renamed} files to match the flashcard format`)files = await readdir(flashcardsDir) // refresh files after rename}let cards = files.filter(file => file.endsWith('.png')).map(file => {const parsed = parseFilename(file)!return {filename: file,...parsed,fullPath: path.join(flashcardsDir, file),}})if (cards.length === 0) {open(`file://${flashcardsDir}`)await div(md(`## No flashcards found in ${flashcardsDir}Just add images (must be pngs!) there (I've opened the folder for you).Don't worry about the filename, it will be renamed automatically on the next run.After you are done, just rerun this script`))exit()}cards.sort((a, b) => {if (a.nextReviewDate < formatDate(today) && b.nextReviewDate >= formatDate(today)) return -1if (a.nextReviewDate >= formatDate(today) && b.nextReviewDate < formatDate(today)) return 1return a.orderInBucket - b.orderInBucket})const reviewDays = [1, 2, 4, 7, 14, 30]const shortcuts = {"remember": 'r',"forgot": 'f',"delete": 'd',}const cardsToReview = cards.filter(card => {const reviewDate = new Date(card.nextReviewDate)return reviewDate <= today})const { size } = await getActiveScreen()for (const card of cardsToReview) {const days = reviewDays[Math.min(card.numberOfReviewsDone, reviewDays.length - 1)]const nextReview = new Date()nextReview.setDate(today.getDate() + days)await div({x: 50,y: 50,height: size.height - 100,width: size.width - 100,enter: "Close",hint: `${cardsToReview.length} cards to review`,html: `<imgsrc="file://${card.fullPath}"style="width: 100%; height: 100%; object-fit: contain"/>`,className: "h-full w-full", // Otherwise the image could grow too much and cause y scrollshortcuts: [// Right bar{name: "Remember",key: "Space",bar: "right",onPress: async () => {const newFilename = formatFilename(formatDate(nextReview),card.orderInBucket,card.numberOfReviewsDone + 1)await rename(card.fullPath,path.join(flashcardsDir, newFilename))submit(null)},},// Left bar{name: "Forgot",key: shortcuts.forgot,bar: "left",onPress: async () => {const lastCard = cards.reduce((max, card) => Math.max(max, card.orderInBucket), 0)const newFilename = formatFilename(formatDate(today), lastCard + 1, 0)await rename(card.fullPath, path.join(flashcardsDir, newFilename))submit(null)},},{name: "Delete",key: shortcuts.delete,bar: "left",onPress: async () => {await trash(card.fullPath)submit(null)},},{name: "Reveal in Finder",key: "r",bar: "left",onPress: async () => {// -R reveals the file in Finder instead of opening itawait exec(`open -R "${card.fullPath}"`)},},],})}say(`Time flows like water,Knowledge grows with each review,Mind blooms day by day`)await div(md(`# That's all folks!All flashcards have been reviewed.See ya next time!`))// Helpers// ===function parseFilename(filename: string) {const match = filename.match(/^CARD_(\d{4}-\d{2}-\d{2})_(\d+)_(\d+)\.png$/)if (!match) return nullreturn {nextReviewDate: match[1],orderInBucket: parseInt(match[2], 10),numberOfReviewsDone: parseInt(match[3], 10),}}function formatFilename(nextReviewDate: string,orderInBucket: number,numberOfReviewsDone: number) {return `CARD_${nextReviewDate}_${orderInBucket}_${numberOfReviewsDone}.png`}function currentMaxForDate(date: string) {const files = readdirSync(flashcardsDir)return files.filter(file => file.startsWith(`CARD_${date}_`)).map(file => parseFilename(file)!.orderInBucket).reduce((max, order) => Math.max(max, order), 0)}function formatDate(date: Date) {return date.toISOString().slice(0, 10)}
// Name: Speed Up Video// Description: Speed up a video, optionally keeping the first and last X seconds at original speed// Author: Strajkimport '@johnlindquist/kit';// Get the video file path, either from selection or promptimport { Choice } from '@johnlindquist/kit';const videoPath = await getSelectedFile() || await path({ placeholder: 'Select a video file' });const { stdout: videoDurationRaw } = await $`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${videoPath}`const videoDuration = parseFloat(videoDurationRaw)// Not sure if best way of handling error statesif (!videoDuration || videoDuration < 1) {await div(`${videoPath} does not look like a video file, exiting`)exit()}const { dir, name, ext } = path.parse(videoPath);const outputPath = path.join(dir, `${name}-adjusted${ext}`);const speedOptions: Choice[] = [{ name: `2x (${Math.round(videoDuration / 2)}s)`, value: 2 },{ name: `3x (${Math.round(videoDuration / 3)}s)`, value: 3 },{ name: `4x (${Math.round(videoDuration / 4)}s)`, value: 4 },{ name: `5x (${Math.round(videoDuration / 5)}s)`, value: 5 },];// Prompt user to select a speedconst speedRaw = await arg({placeholder: `Select new playback speed, or enter a custom value`,hint: `Any number or ";5;3;6" for 5x speed-up, but first 3 and last 6s are kept at original speed`,choices: speedOptions,strict: false, // allow arbitrary input});let mode: 'simple' | 'complex' = speedRaw.includes?.(';') ? 'complex' : 'simple'if (mode === 'simple') {let speed: number = parseFloat(speedRaw)// -filter:v means video filter// -filter:a means audio filter// for uploading to twitter, i'm setting fps to 30await term(`ffmpeg \-i "${videoPath}" \-filter:v "setpts=${1 / speed}*PTS,fps=30" \-filter:a "atempo=${speed}" \-y "${outputPath}"`)} else {let [speedStr, introStr, outroStr] = speedRaw.split(';')let speed = parseFloat(speedStr)let intro = parseInt(introStr)let outro = parseInt(outroStr)// Note: ffmpeg is savageawait term(`ffmpeg -i "${videoPath}" -filter_complex "[0:v]split=3[v1][v2][v3];[v1]trim=0:${intro},setpts=PTS-STARTPTS,fps=30[first];[v2]trim=${intro}:${videoDuration - outro},setpts=PTS-STARTPTS,setpts=${1 / speed}*PTS,fps=30[middle];[v3]trim=${videoDuration - outro},setpts=PTS-STARTPTS,fps=30[last];[first][middle][last]concat=n=3:v=1:a=0[outv]" -y -map "[outv]" "${outputPath}"`)}// Reveal output file in the system's file explorerawait revealFile(outputPath);
// Name: Chrome Profiles// Description: List Chrome profiles and copy their path to clipboard/open in Finder// Author: Strajkimport '@johnlindquist/kit'import { Choice } from '@johnlindquist/kit/types';const chromeAppSupportPaths = [home('Library/Application Support/Google/Chrome/'),home('Library/Application Support/Google/Chrome Canary/'),home('Library/Application Support/Google/Chrome Dev/'),]let choices: Choice[] = []for (const chromeAppSupportPath of chromeAppSupportPaths) {if (!await pathExists(chromeAppSupportPath)) continueconst subdirs = await readdir(chromeAppSupportPath)for (const subdir of subdirs) {const profilePath = path.join(chromeAppSupportPath, subdir)const profilePreferencesPath = path.join(profilePath, 'Preferences')if (!await pathExists(profilePreferencesPath)) continuetry {const preferencesRaw = await readFile(profilePreferencesPath, 'utf-8')const preferencesJson = JSON.parse(preferencesRaw)const useful = pickUsefulFromPreferences(preferencesJson)let title = useful.accountEmail || useful.profileNametitle += ` (created ${useful.profileCreationTime ? useful.profileCreationTime.toISOString().split('T')[0] : ''})`let description = profilePathlet tag = ''if (chromeAppSupportPath.includes('Google/Chrome Canary')) {tag = 'Canary'} else if (chromeAppSupportPath.includes('Google/Chrome Beta')) {tag = 'Beta'} else if (chromeAppSupportPath.includes('Google/Chrome Dev')) {tag = 'Dev'} else if (chromeAppSupportPath.includes('Google/Chrome')) {tag = 'Stable'}let className = ''if (isUnnamedProfile(title)) {className = 'text-gray-400'}choices.push({value: profilePath, // full path always as a valueimg: useful.pictureUrl || useful.avatarUrl,name: title,description,tag,nameClassName: className,})} catch (e) {console.warn(`Error parsing profile at ${profilePreferencesPath}`, e)}}}if (choices.length === 0) {await div("No Chrome profiles found")exit()}// Sort choices: regular profiles alphabetically first, then "Person X" and "Guest" profileschoices.sort((a, b) => {const aIsUnnamed = isUnnamedProfile(a.name)const bIsUnnamed = isUnnamedProfile(b.name)// If both are Unnamed profiles or both are not, sort alphabeticallyif ((aIsUnnamed && bIsUnnamed) || (!aIsUnnamed && !bIsUnnamed)) {return a.name.localeCompare(b.name)}// Put Unnamed profiles at the endreturn aIsUnnamed ? 1 : -1})let selectedProfile = await arg({placeholder: 'Select a profile',enter: 'Copy Path',actions: [{name: 'Open in Finder',onAction: async (input, { focused }) => {await revealFile(focused.value)},},]}, choices)if (selectedProfile) {await clipboard.writeText(selectedProfile)notify(`Copied Profile path to clipboard`)}// HELPERS// ===function isUnnamedProfile(title: string) {return title.match(/Person \d+|Guest/)}// Converts Windows FILETIME timestamp (100-nanosecond intervals since January 1, 1601)// to Unix timestamp (seconds since January 1, 1970)// See: https://learn.microsoft.com/en-us/windows/win32/sysinfo/file-timesfunction windowsTimestampToDate(windowsTime: number): Date {try {const n = Number(windowsTime) / 1e6 - 11644473600;return new Date(n * 1000);} catch (e) {console.warn(`Error converting Windows timestamp ${windowsTime} to Date`, e);return undefined}}function pickUsefulFromPreferences(preferencesJson: any) {return {accountEmail: preferencesJson.account_info?.[0]?.email,accountName: preferencesJson.account_info?.[0]?.full_name,pictureUrl: preferencesJson.account_info?.[0]?.picture_url,avatarUrl: avatarIdToUrl(preferencesJson.profile?.avatar_index),profileName: preferencesJson.profile?.name,profileCreationTime: windowsTimestampToDate(preferencesJson.profile?.creation_time),lastEngagementTime: windowsTimestampToDate(preferencesJson.profile?.last_engagement_time),extensionsCount: Object.keys(preferencesJson.extensions.install_signature?.ids || {}).length,}}function chromeImageUrl(name: "stable" | "canary" | "dev" | "beta") {let url = `https://raw.githubusercontent.com/chromium/chromium/refs/heads/main/chrome/app/theme/default_100_percent/common/`if (name === "stable") {url += "product_logo_16.png"} else if (name === "canary") {url += "product_logo_32_canary.png"} else if (name === "dev") {url += "product_logo_32_dev.png"} else {url += "product_logo_32_beta.png"}return url}function avatarIdToUrl(avatarId: number) {// from https://gitlab.developers.cam.ac.uk/jz448/browser-android-tabs/-/blob/base-75.0.3770.67-brave-ads/chrome/app/theme/theme_resources.grdconst mapIdToFile = {0: 'profile_avatar_generic.png',1: 'profile_avatar_generic_aqua.png',2: 'profile_avatar_generic_blue.png',3: 'profile_avatar_generic_green.png',4: 'profile_avatar_generic_orange.png',5: 'profile_avatar_generic_purple.png',6: 'profile_avatar_generic_red.png',7: 'profile_avatar_generic_yellow.png',8: 'profile_avatar_secret_agent.png',9: 'profile_avatar_superhero.png',10: 'profile_avatar_volley_ball.png',11: 'profile_avatar_businessman.png',12: 'profile_avatar_ninja.png',13: 'profile_avatar_alien.png',14: 'profile_avatar_awesome.png',15: 'profile_avatar_flower.png',16: 'profile_avatar_pizza.png',17: 'profile_avatar_soccer.png',18: 'profile_avatar_burger.png',19: 'profile_avatar_cat.png',20: 'profile_avatar_cupcake.png',21: 'profile_avatar_dog.png',22: 'profile_avatar_horse.png',23: 'profile_avatar_margarita.png',24: 'profile_avatar_note.png',25: 'profile_avatar_sun_cloud.png',26: 'profile_avatar_placeholder.png',27: 'modern_avatars/origami/avatar_cat.png',28: 'modern_avatars/origami/avatar_corgi.png',29: 'modern_avatars/origami/avatar_dragon.png',30: 'modern_avatars/origami/avatar_elephant.png',31: 'modern_avatars/origami/avatar_fox.png',32: 'modern_avatars/origami/avatar_monkey.png',33: 'modern_avatars/origami/avatar_panda.png',34: 'modern_avatars/origami/avatar_penguin.png',35: 'modern_avatars/origami/avatar_pinkbutterfly.png',36: 'modern_avatars/origami/avatar_rabbit.png',37: 'modern_avatars/origami/avatar_unicorn.png',38: 'modern_avatars/illustration/avatar_basketball.png',39: 'modern_avatars/illustration/avatar_bike.png',40: 'modern_avatars/illustration/avatar_bird.png',41: 'modern_avatars/illustration/avatar_cheese.png',42: 'modern_avatars/illustration/avatar_football.png',43: 'modern_avatars/illustration/avatar_ramen.png',44: 'modern_avatars/illustration/avatar_sunglasses.png',45: 'modern_avatars/illustration/avatar_sushi.png',46: 'modern_avatars/illustration/avatar_tamagotchi.png',47: 'modern_avatars/illustration/avatar_vinyl.png',48: 'modern_avatars/abstract/avatar_avocado.png',49: 'modern_avatars/abstract/avatar_cappuccino.png',50: 'modern_avatars/abstract/avatar_icecream.png',51: 'modern_avatars/abstract/avatar_icewater.png',52: 'modern_avatars/abstract/avatar_melon.png',53: 'modern_avatars/abstract/avatar_onigiri.png',54: 'modern_avatars/abstract/avatar_pizza.png',55: 'modern_avatars/abstract/avatar_sandwich.png'}const file = mapIdToFile[avatarId]return `https://raw.githubusercontent.com/chromium/chromium/refs/heads/main/chrome/app/theme/default_100_percent/common/${file}`}
// Name: Night Shift// Description: Control Night Shift on macOS// Author: Pavel 'Strajk' Dolecek// Acknowledgements:// - https://github.com/smudge/nightlight// - https://github.com/shmulvad/alfred-nightshift/// Notes:// nightlight CLI usage:// on/off: `nightlight on|off|toggle`, status will show current state// temp: `nightlight temp [0-100]`, no argument will show current temperature// schedule: `nightlight schedule [start|off|HH:MM HH:MM]`import "@johnlindquist/kit"import {Choice} from "@johnlindquist/kit";async function main() {await execa('which', ['nightlight']).catch(async () => {let choice = await arg({placeholder: `Nightlight CLI required, but not installed, install?`,choices: ['Yes', 'No']})if (choice !== 'Yes') {await notify(`OK, not installing Nightlight CLI & exiting`)exit() // exit whole script}console.log(`Installing Nightlight CLI... on Apple Silicon it might take a while because precompiled binaries are not available yet, see https://github.com/smudge/nightlight/issues/22`)await terminal(`brew install smudge/smudge/nightlight`)await arg("Press enter when installing in terminal finishes...")console.clear() // Clear previous console.logreturn main()})let status = execaSync('nightlight', ['status']).stdoutlet temp = execaSync('nightlight', ['temp']).stdoutlet choices: Choice[] = [{name: 'Toggle',value: 'toggle'}, {name: 'Off',value: 'off'}, {name: 'On',value: 'on'}, {name: 'On & 25%',value: 'temp 25'}, {name: 'On & 50%',value: 'temp 50'}, {name: 'On & 75%',value: 'temp 75'}, {name: 'On & 100%',value: 'temp 100'}]let choice = await arg({placeholder: `Either select or type exact amount from 0 to 100`,hint: `Current status: ${uppercaseFirst(status)} • Current temperature: ${temp}`,choices,strict: false // allow arbitrary input})let args = choice.split(' ')let isArg0Number = !isNaN(parseInt(args[0]))if (isArg0Number) {args = ['temp', args[0]]}if (args[0] === 'temp') {// if setting temp, turn on firstawait execa('nightlight', ['on'])}await execa('nightlight', args).then(() => notify(`Nightlight set to: ${choice}`)).catch(() => notify(`Failed to set Nightlight to: ${choice}`))}void main()function uppercaseFirst(str: string) {return str.charAt(0).toUpperCase() + str.slice(1)}
// Name: Faker// Description: Generate fake data with faker.js// Author: Pavel 'Strajk' Dolecek// Twitter: @straaajkimport "@johnlindquist/kit"import { faker } from '@faker-js/faker';import { Choice } from "@johnlindquist/kit";const CACHE_EXPIRATION = 1000 * 60 * 60 * 24 * 30 // 1 month// const CACHE_EXPIRATION = 1000 * 15 // 15 seconds // uncomment for debugging// @ts-ignore kitCommand is defined, but it's not in types :(const keyv = await store(kitCommand, {cachedChoices: null, cachedChoicesExpires: null})const maybeCachedChoices = await keyv.get('choices') as Choice[] | nullconst maybeCachedChoicesExpires = await keyv.get('cachedChoicesExpires') as number | nullconst EXAMPLES_MAX_COUNT = 5const EXAMPLES_MAX_CHARS = 120const modulesToIgnore = ["_defaultRefDate","_randomizer","datatype", // just boolean"helpers","rawDefinitions","definitions",]const methodsToIgnore = ["faker",]const pairsToIgnore = ["internet.userName", // deprecated in favour of internet.username"image.avatarLegacy", // deprecated in favour of image.avatar// requires params, which would require special handling"string.fromCharacters","date.between","date.betweens",]let choices = []if (maybeCachedChoices?.length > 0&& maybeCachedChoicesExpires > Date.now()) {// console.log("Using cached choices")choices = maybeCachedChoices as Choice[]} else {// console.log("Generating choices")for (const module in faker) { // date, number, string, finance, ...if (modulesToIgnore.includes(module)) {continue;}for (const method in faker[module]) {if (methodsToIgnore.includes(method)) {continue;}const pair = `${module}.${method}`;if (pairsToIgnore.includes(pair)) {continue;}let examplesCount = 0;let examplesText = "";while (examplesText.length < EXAMPLES_MAX_CHARS && examplesCount < EXAMPLES_MAX_COUNT) {const newExample = callFaker(module, method);if (examplesText.length > 0) {examplesText += " • "}examplesText += toString(newExample);examplesCount++;}examplesText = examplesText.trim().substring(0, EXAMPLES_MAX_CHARS);choices.push({name: `${module}: ${method}`,value: pair,description: examplesText,})}}await keyv.set('choices', choices)await keyv.set('cachedChoicesExpires', Date.now() + CACHE_EXPIRATION)}const selected = await arg({placeholder: "Select a Faker module and method",choices: choices,enter: "Copy to clipboard",})let [module, method] = selected.split(".")let value = toString(callFaker(module, method))await copy(value)await notify(`Copied: ${value}`)// Helpers// ===function callFaker(module: string, method: string) {try {return faker[module][method]()} catch (error) {console.error(`Error calling faker method: ${module}.${method}`, error);return}}function toString(value: any): string {if (typeof value === "string") {return value} else if (typeof value === "object") {return JSON.stringify(value)} else {return value?.toString() || "❌"}}
// Name: Karabiner Elements Profile Switcher// Acknowledgements:// - https://www.alfredforum.com/topic/9927-karabiner-elements-profile-switcher/// Notes:// - Probably could also work with cli https://github.com/raycast/extensions/blob/be03024b4c4f4f1ad0af7f4d20ea4630d7f0ee20/extensions/karabiner-profile-switcher/src/model/KarabinerManager.tsimport "@johnlindquist/kit"import fs from 'fs'const CONFIG_PATH = home('.config/karabiner/karabiner.json')const configJson = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))const profileChoices = configJson.profiles.map((profile: any) => ({name: profile.name,value: profile.name,tag: profile.selected ? '🟢' : '', // Note: Not sure if there's a better way to highlight currenly active}))const selectedProfile = await arg({placeholder: 'Select a profile',choices: profileChoices,})for (const profile of configJson.profiles) {profile.selected = profile.name === selectedProfile // intentional mutation}fs.writeFileSync(CONFIG_PATH, JSON.stringify(configJson, null, 2))notify(`Karabiner Elements profile switched to "${selectedProfile}"`)
// Name: Disposable email// Description: Generate a disposable email address, open the inbox in the browser, and copy the email address to the clipboard// Acknowledgments:// - https://www.alfredforum.com/topic/4643-temporary-email-%E2%80%94-generate-disposable-email-inboxes/import "@johnlindquist/kit"import { uniqueNamesGenerator, adjectives, animals, colors } from 'unique-names-generator';// The ones with auto: true will copy the email address to clipboard, the ones with auto: false will notconst providers = {"maildrop.cc": { name: "Maildrop", auto: true },"harakirimail.com": { name: "Harakirimail", auto: true },"incognitomail.co": { name: "Incognitomail", auto: false },"temporarymail.com": { name: "Temporarymail", auto: false },"mail.tm": { name: "Mail.tm", auto: false },"dropmail.me": { name: "Dropmail", auto: false },"guerrillamail.com": { name: "Guerrillamail", auto: false }};let provider = await arg({placeholder: `Select Provider`,choices: Object.entries(providers).map(([name, def]) => ({name: def.name,description: def.auto ? `🤖 Auto-copies email to clipboard` : `🫵 Manually copy email from website`,value: name}))});let def = providers[provider];if (!def) throw new Error(`Invalid provider: ${provider}`);if (def.auto) {const desiredEmailName = uniqueNamesGenerator({dictionaries: [adjectives, animals],length: 2,separator: '-',});const email = `${desiredEmailName}@${provider}`;let url: string;if (provider === 'maildrop.cc') {url = `https://${provider}/inbox/?mailbox=${desiredEmailName}`;} else {url = `https://${provider}/inbox/${desiredEmailName}`;}await clipboard.writeText(email);await open(url);await notify(`Email: ${email} copied to clipboard`);} else {await open(`https://${provider}`);await notify(`Get the email address from the website`);}
// Name: Clear MacOS notifications// Description: Only visible notifications – clearing not visible notifications is not possibleimport "@johnlindquist/kit"await jxa(`Application("System Events").applicationProcesses.byName("NotificationCenter").windows[0].groups[0].scrollAreas[0].uiElements[0].groups().map(banner => banner.actions().slice(-1)[0]).forEach(banner => banner.perform())`)// Inspired by Kit's "applescript" functionasync function jxa(script: string) {await writeFile(kenvTmpPath("clear-macos-notifications.jxa"), script);await execa("osascript", ["-l", "JavaScript", kenvTmpPath("clear-macos-notifications.jxa")]);}
// Name: Lyrics on Genius// Description: Look up Lyrics of Current Song on Rap Genius// Acknowledgments:// - Ryan Rudzitis: Look up Lyrics of Current Song on Rap Geniusimport "@johnlindquist/kit"let appsToTry = ["Music","Spotify"]let qs: stringfor (let app of appsToTry) {qs = await applescript(/* applescript */ `tell application "${app}"if player state is playing thenset aTrack to the current trackset aName to name of aTrackset aArtist to artist of aTrackreturn quoted form of (aArtist & " - " & aName)elsereturn ""end ifend tell`)if (qs) break}if (!qs) {notify(`No music is playing in: ` + appsToTry.join(", "))} else {open(`http://genius.com/search?q=${qs}`)}
// Name: Messages 2FA codes// Description: Search for 2FA codes in your Messages, within the last 30 minutes// Ackowledgements:// - https://github.com/squatto/alfred-imessage-2fa/// - https://github.com/raycast/extensions/tree/main/extensions/imessage-2faimport "@johnlindquist/kit"import Database from 'better-sqlite3';let preferences = {lookBackMinutes: 30,ignoreRead: false,}export type TMessage = {guid: string;message_date: string; // 2024-11-26 06:11:18sender: string; // e.g. amazon.de or +49123456789service: string; // e.g. SMStext: string;}const db = new Database(home("Library/Messages/chat.db"));let output = await arg({placeholder: "Select a message or start typing to search",choices: async (input) => {let stmt = db.prepare(dbQuery(input));let messages = stmt.all() as TMessage[];return messages.map((m) => ({name: m.text,tag: extractCode(m.text) ?? "no code",description: `${m.message_date} • ${m.sender} • ${m.service}`,value: m.text,preview: `<div class="p-2 text-sm">${m.text}</div>`,}));},actions: [{name: "Copy Whole Message",flag: "copyWholeMessage",visible: true,shortcut: `${cmd}+c`,},]})if (flag.copyWholeMessage) {clipboard.writeText(output)notify("Whole message copied to clipboard")} else {let code = extractCode(output)if (code) {clipboard.writeText(code)notify(`Code: ${code} copied to clipboard`)} else {clipboard.writeText(output)notify("No code found. Copied whole message to clipboard instead")}}// Helpers// ===function dbQuery(qs: string = "") {let baseQuery = /* sql */`selectmessage.guid,message.rowid,ifnull(handle.uncanonicalized_id, chat.chat_identifier) AS sender,message.service,datetime(message.date / 1000000000 + 978307200, 'unixepoch', 'localtime') AS message_date,message.textfrom messageleft join chat_message_join on chat_message_join.message_id = message.ROWIDleft join chat on chat.ROWID = chat_message_join.chat_idleft join handle on message.handle_id = handle.ROWIDwhere message.is_from_me = 0and message.text is not nulland length(message.text) > 0anddatetime(message.date / 1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime')>=datetime('now', '-${preferences.lookBackMinutes} minutes', 'localtime')`;if (preferences.ignoreRead) baseQuery += " and message.is_read = 0";if (!qs) { // search for codebaseQuery = /* sql */`${baseQuery} and (-- Matches 3 alphanumeric (e.g., 'ABC')message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z]*'-- Matches 4 alphanumeric (e.g., 'ABCD')or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'-- Matches 5 alphanumeric (e.g., 'ABCDE')or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'-- Matches 6 alphanumeric (e.g., 'ABCDEF')or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'-- Matches format '123-456'or message.text glob '*[0-9][0-9][0-9]-[0-9][0-9][0-9]*'-- Matches 7 alphanumeric (e.g., 'ABCDEFG')or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'-- Matches 8 alphanumeric (e.g., 'ABCDEFGH')or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*')`;} else { // Search for textbaseQuery = /* sql */`${baseQuery} and message.text like '%${qs}%'`;}return `${baseQuery} \norder by message.date desc limit 100`.trim();}export function extractCode(original: string) {// remove URLsconst urlRegex = new RegExp("\\b((https?|ftp|file):\\/\\/|www\\.)[-A-Z0-9+&@#\\/%?=~_|$!:,.;]*[A-Z0-9+&@#\\/%=~_|$]","ig");let message = original.replaceAll(urlRegex, "");if (message.trim() === "") return "";let m;let code;// Look for specific patterns firstif ((m = /^(\d{4,8})(\sis your.*code)/.exec(message)) !== null) {// 4-8 digits followed by "is your [...] code"// examples:// "2773 is your Microsoft account verification code"code = m[1];} else if ((m = /(code\s*:|is\s*:|码|use code|autoriza(?:ca|çã)o\s*:|c(?:o|ó)digo\s*:)\s*(\w{4,8})($|\s|\\R|\t|\b|\.|,)/i.exec(message)) !== null) {// "code:" OR "is:" OR "use code", optional whitespace, then 4-8 consecutive alphanumeric characters// examples:// "Your Airbnb verification code is: 1234."// "Your verification code is: 1234, use it to log in"// "Here is your authorization code:9384"// "【抖音】验证码9316,用于手机验证"// "Your healow verification code is : 7579."// "TRUSTED LOCATION PASSCODE: mifsuc"// "Código de Autorização: 12345678"code = m[2];} else {// more generic, brute force patterns// remove phone numbers// we couldn't do this before, because some auth codes resemble text shortcodes, which would be filtered by this regexconst phoneRegex = new RegExp(// https://stackoverflow.com/a/123666/(?:(?:\+?1\s*(?:[.-]\s*)?)?(?:\(\s*([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\s*\)|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\s*(?:[.-]\s*)?)?([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\s*(?:[.-]\s*)?([0-9]{4})(?:\s*(?:#|x\.?|ext\.?|extension)\s*(\d+))?/,"ig");const originalMessage = message;message = message.replaceAll(phoneRegex, "");if ((m = /(^|\s|\\R|\t|\b|G-|:)(\d{5,8})($|\s|\\R|\t|\b|\.|,)/.exec(message)) !== null) {// 5-8 consecutive digits// examples:// "您的验证码是 199035,10分钟内有效,请勿泄露"// "登录验证码:627823,您正在尝试【登录】,10分钟内有效"// "【赛验】验证码 54538"// "Enter this code to log in:59678."// "G-315643 is your Google verification code"// "Enter the code 765432, and then click the button to log in."// "Your code is 45678!"// "Your code is:98765!"code = m[2];} else if ((m = /\b(?=[A-Z]*[0-9])(?=[0-9]*[A-Z])[0-9A-Z]{3,8}\b/.exec(message)) !== null) {// 3-8 character uppercase alphanumeric string, containing at least one letter and one number// examples:// "5WGU8G"// "Your code is: 5WGU8G"// "CWGUG8"// "CWGUG8 is your code"// "7645W453"code = m[0];} else if ((m = /(^|code:|is:|\b)\s*(\d{3})-(\d{3})($|\s|\\R|\t|\b|\.|,)/i.exec(message)) !== null) {// line beginning OR "code:" OR "is:" OR word boundary, optional whitespace, 3 consecutive digits, a hyphen, then 3 consecutive digits// but NOT a phone number (###-###-####)// examples:// "123-456"// "Your Stripe verification code is: 719-839."// and make sure it isn't a phone number// doesn't match: <first digits>-<second digits>-<4 consecutive digits>const first = m[2];const second = m[3];code = `${first}${second}`;} else if ((m = /(code|is):?\s*(\d{3,8})($|\s|\\R|\t|\b|\.|,)/i.exec(originalMessage)) !== null) {// "code" OR "is" followed by an optional ":" + optional whitespace, then 3-8 consecutive digits// examples:// "Please enter code 548 on Zocdoc."code = m[2];} else {// console.log("no code found in message");}}return code;}
// Name: Keyboard Maestro// Description: Run or edit a Keyboard Maestro macro// Author: Pavel 'Strajk' Dolecek// Twitter: @straaajk// Cache: trueimport "@johnlindquist/kit"import plist from 'plist'import dedent from "dedent"export type TMacro = Partial<{name: stringuid: stringactive: booleancreated: numberused: numberenabled: booleanlastused: numbermodified: numbersaved: numbersort: stringtriggers: Array<Partial<{ description: string, short: string, type: string }>>}>export type TMacroGroup = Partial<{uid: stringenabled: booleanname: stringsort: stringmacros: TMacro[]}>let kmMacrosRaw = await applescript(dedent`tell application "Keyboard Maestro Engine"getmacros with asstringend tell`)let kmMacrosParsed = plist.parse(kmMacrosRaw) as TMacroGroup[]const choices = kmMacrosParsed.flatMap(group => group.macros.map(macro => ({name: `${group.name} - ${macro.name}`,value: macro.uid})))let chosen = await arg({placeholder: "Choose a macro",choices: choices,actions: [{shortcut: `${cmd}+e`,name: "Edit Macro",visible: true,flag: "edit"}]})if (flag.edit) { // Note that flag is a globalawait applescript(dedent`tell application "KeyboardMaestro"editMacro "${chosen}"activateend tell`)} else {await applescript(dedent`tell application "Keyboard Maestro Engine"do script "${chosen}"end tell`)}
// Name: Rename script files to match their name// Description: Renames script file names to match their name (// Name: Foo Bar -> foo-bar.ts)import "@johnlindquist/kit"import { stripName } from "@johnlindquist/kit"import * as fs from "fs/promises"import dedent from "dedent"// just for documentation purposeslet exampleScript = {"command": "rename-script-files-to-match-their-name","filePath": "/Users/strajk/.kenv/scripts/rename-script-files-to-match-their-name.ts","id": "/Users/strajk/.kenv/scripts/rename-script-files-to-match-their-name.ts","name": "Rename script files to match their name","timestamp": 1732375034783,"type": "Prompt"// ...}let scripts = await getScripts()for (let script of scripts) {let normalizedName = stripName(script.name)let extname = path.extname(script.filePath)let basename = path.basename(script.filePath, extname)if (basename !== script.command) {console.log(dedent`This should never happen, basename and command should be the same:basename: ${basename}command: ${script.command}`)notify(`basename: ${basename} command: ${script.command}`)exit()}if (normalizedName !== basename) {console.log(dedent`"${script.name}":current: ${basename}normalized: ${normalizedName}`)let confirmed = await arg({placeholder: `Rename "${basename}" to "${normalizedName}"`,choices: [{ name: "Yes", value: true },{ name: "No", value: false }]})if (!confirmed) {continue}console.log(`Renaming "${basename}" to: ${normalizedName}`)let newPath = path.join(path.dirname(script.filePath), normalizedName + extname)await fs.rename(script.filePath, newPath)}}
// Name: Install app from dmg in Downloads/// Implementation notes:/// - dmg file name can be different from mounted volume name, e.g. UIBrowser302.dmg -> /Volumes/UI Browser 3.0.2.0/// - dmg might contain License agreement that needs to be accepted, e.g. UIBrowser302.dmg/// - dmg might contain other files than just the app, e.g. Extras folder and README.rtf, see UIBrowser302.dmgimport "@johnlindquist/kit"import fs, {statSync, unlinkSync} from "fs";import {join} from "path";import * as luxon from "luxon"import {execa} from "execa";import {execSync} from "child_process"let downloadsDir = home("Downloads") // Feel free to changelet dmgPaths = await globby("*.dmg", { cwd: downloadsDir })let dmgObjs = dmgPaths.map(path => ({fullPath: join(downloadsDir, path),baseName: path.split("/").pop()?.replace(".dmg", ""),createdAt: statSync(join(downloadsDir, path)).ctime.getTime(),sizeInMb: statSync(join(downloadsDir, path)).size / 1024 / 1024})).sort((a, b) => b.createdAt - a.createdAt)if (dmgObjs.length === 0) {setPlaceholder("No DMG files found in Downloads directory")} else {let selectedDmgPath = await arg({placeholder: "Which dmg?",choices: dmgObjs.map(dmg => ({value: dmg.fullPath,name: dmg.baseName,description: `${luxon.DateTime.fromMillis(dmg.createdAt).toFormat('yyyy-MM-dd HH:mm')} • ${dmg.sizeInMb.toFixed(2)} MB`}))})console.log(`Mounting ${selectedDmgPath}`)let volumeName = await attachDmg(selectedDmgPath)let mountPath = `/Volumes/${volumeName}`;console.log(`Mounted to ${mountPath}`)// Note: Globby did not work for me for mounted volumeslet apps = fs.readdirSync(mountPath).filter(f => f.endsWith(".app"))if (apps.length === 0) {setPlaceholder("No apps found in the mounted volume")// TODO: Find a better way to do early returns/exits} else {let confirmed = await arg({placeholder: `Found ${apps.length} apps: ${apps.join(", ")}, install?`,choices: ["yes", "no"]})if (confirmed !== "yes") {notify("Aborted")process.exit(0)}for (let app of apps) {console.log(`Copying ${app} to /Applications folder`);await execa(`cp`, ['-a', `${mountPath}/${app}`,'/Applications/']);}console.log(`Detaching ${mountPath}`)await detachDmg(mountPath)let confirmDeletion = await arg({placeholder: `Delete ${selectedDmgPath}?`,choices: ["yes", "no"]})if (confirmDeletion === "yes") {console.log(`Deleting ${selectedDmgPath}`)await trash(selectedDmgPath)}}}// Helpers// ===async function attachDmg(dmgPath: string): Promise<string> {// https://superuser.com/questions/221136/bypass-a-licence-agreement-when-mounting-a-dmg-on-the-command-linelet out = execSync(`yes | PAGER=cat hdiutil attach "${dmgPath}"`).toString()let lines = out.split("\n").reverse()// from the end, find line with volume name// /dev/disk6s2 Apple_HFS /Volumes/UI Browser 3.0.2.0let lineWithVolume = lines.find(line => line.includes("/Volumes/"))if (!lineWithVolume) {throw new Error(`Failed to find volume name in output: ${out}`)}let volumeName = lineWithVolume.split(`/Volumes/`)[1]return volumeName}async function detachDmg(mountPoint: string) {await execa('hdiutil', ['detach', mountPoint])}
// Name: Log Past X Minutes to Calendar// Description: Asks for duration in minutes, event title, and (on first use) calendar name// Author: Pavel 'Strajk' Dolecek <www.strajk.me>// Twitter: @straaajk// me.strajk:status SHAREDconst minutes = await arg("Enter the duration (in minutes) to log retroactively")const title = await arg("Describe the activity you want to log")let calendar = await env("SCRIPTKIT_LOG_CALENDAR_NAME", {hint: `Enter the exact name of an existing calendar in your Calendar app`,})await applescript(`tell application "Calendar"switch view to week viewtell calendar "${calendar}"set theCurrentDate to current datemake new event at end with properties {summary:"${title}", start date:theCurrentDate - ${minutes} * minutes, end date:theCurrentDate}end tellend tell`)await notify(`Logged ${minutes} minutes of ${title} to ${calendar}`)
// Menu: Super Search across multiple websites// Description: Search multiple websites, in bulk, in your browser// Author: Pavel 'Strajk' Dolecek <www.strajk.me>// Twitter: @straaajk//// Shortcut: command option ;// me.strajk:status WIP// BEWARE: Discord search requires my userscript:// https://github.com/Strajk/setup/blob/master/user-scripts/discord-search-from-q-url-param.user.jslet templates = {discord: `https://discord.com/channels/{slug}/?q={query}`,githubRepo: `https://github.com/{slug}/issues?q={query}`,githubDiscussion: `https://github.com/{slug}/discussions?discussions_q={query}`,twitter: `https://x.com/search?q=from%3A{slug}+{query}`,reddit: `https://www.reddit.com/r/{slug}/search?q={query}`,// TODO:// Slack: But even harder than Discord https://stackoverflow.com/questions/51541986/a-way-to-open-up-slack-search-ui-in-a-browser-from-a-url}// Each topic can only use search templates defined above (discord, githubRepo, githubDiscussion)// but doesn't need to include all of them - just some are enough// ⬇⬇⬇ EDIT TO YOUR LIKING ⬇⬇⬇let topics: Record<string, Partial<{[K in keyof typeof templates]: string[]}>> = {kit: {discord: [`804053880266686464`],githubRepo: [`johnlindquist/kit`],githubDiscussion: [`johnlindquist/kit`],twitter: [`scriptkitapp`],},litellm: {discord: [`1123360753068540065`],githubRepo: [`BerriAI/litellm`],githubDiscussion: [`BerriAI/litellm`],twitter: [`LiteLLM`],},wxt: {discord: [`1212416027611365476`],githubRepo: [`wxt-dev/wxt`],githubDiscussion: [`wxt-dev/wxt`]},coolify: {discord: [`459365938081431553`],githubRepo: [`coollabsio/coolify`],githubDiscussion: [`coollabsio/coolify`],twitter: [`coolify`],},crawlee: {discord: [`801163717915574323`],githubRepo: [`apify/crawlee`],githubDiscussion: [`apify/crawlee`]},mantine: {discord: [`854810300876062770`],githubRepo: [`mantinedev/mantine`],githubDiscussion: [`orgs/mantinedev`], // note it's different from repo abovetwitter: [`mantinedev`],},supabase: {discord: [`839993398554656828`],githubDiscussion: [`orgs/supabase`],reddit: [`Supabase`],githubRepo: [`supabase/supabase`,`supabase/supabase-js`,`supabase/cli`,`supabase/postgrest-js`,`supabase/supabase-py`,],},pnpm: {discord: [`731599538665553971`],githubRepo: [`pnpm/pnpm`],githubDiscussion: [`pnpm/pnpm`],},ai: {discord: [`1110910277110743103`, // superagent`1153072414184452236`, // autogen`822583790773862470`, // latentspace`1122748573000409160`, // ai stack devs`877056448956346408`, // lablablab],},nextjs: {discord: [`752553802359505017`, // nextjs`966627436387266600`, // theo typesafe cult],},plasmo: {githubDiscussion: [`PlasmoHQ/plasmo`],githubRepo: [`PlasmoHQ/plasmo`],discord: [`946290204443025438`],},scraping: {discord: [`646150246094602263`, // scraping in prod`851364676688543744`, // scrapy`737009125862408274`, // scraping enthusiasts],}}let hint = `${isMac ? `⌘` : `Control`}+o to edit`let type = await arg({placeholder: `What topic? ${hint}`,choices: Object.keys(topics),})let query = await arg('Query?')let topicObj = topics[type] // e.g. { discord: ['123', '345'], github: ['foo'] }for (const [key, slugs] of Object.entries(topicObj)) {// key is e.g. discord, githubIssues, githubDiscussions, ...// slugs are e.g. ['12356', 'facebook/react']for (const slug of slugs) {// slug e.g. '12356', 'facebook/react'let urlTemplate = templates[key] // e.g. 'github.com/{id}/issues?q={query}'let url = urlTemplate.replace('{slug}', slug) // e.g. 'facebook/react'.replace('{query}', encodeURI(query)) // e.g. 'foo'exec(`open ${url}`)}}