Community Scripts

Use the jikan.moe API to search anime

// Menu: Search Anime
// Description: Use the jikan.moe API to search anime
// Author: John Lindquist
// Twitter: @johnlindquist
let anime = await arg("Anime:")
let response = await get(
`https://api.jikan.moe/v3/search/anime?q=${anime}`
)
let { image_url, title } = response.data.results[0]
showImage(image_url, { title })

Search for an app then launch it

// Menu: App Launcher
// Description: Search for an app then launch it
// Author: John Lindquist
// Twitter: @johnlindquist
let createChoices = async () => {
let apps = await fileSearch("", {
onlyin: "/",
kind: "application",
})
let prefs = await fileSearch("", {
onlyin: "/",
kind: "preferences",
})
let group = path => apps =>
apps
.filter(app => app.match(path))
.sort((a, b) => {
let aName = a.replace(/.*\//, "")
let bName = b.replace(/.*\//, "")
return aName > bName ? 1 : aName < bName ? -1 : 0
})
return [
...group(/^\/Applications\/(?!Utilities)/)(apps),
...group(/\.prefPane$/)(prefs),
...group(/^\/Applications\/Utilities/)(apps),
...group(/System/)(apps),
...group(/Users/)(apps),
].map(value => {
return {
name: value.split("/").pop().replace(".app", ""),
value,
description: value,
}
})
}
let appsDb = await db("apps", async () => ({
choices: await createChoices(),
}))
let app = await arg("Select app:", appsDb.choices)
let command = `open -a "${app}"`
if (app.endsWith(".prefPane")) {
command = `open ${app}`
}
exec(command)

Use Open Library API to search for books

// Menu: Book Search
// Description: Use Open Library API to search for books
// Author: John Lindquist
// Twitter: @johnlindquist
let query = await arg('Search for a book title:')
//This API can be a little slow. Wait a couple seconds
let response = await get(`http://openlibrary.org/search.json?q=${query}`)
let transform = ({title, author_name}) =>
`* "${title}" - ${author_name?.length && author_name[0]}`
let markdown = response.data.docs.map(transform).join('\n')
inspect(markdown, 'md')

center-app

by John Lindquist
Add to Kit.app+

Center the frontmost app

// Menu: Center App
// Description: Center the frontmost app
// Author: John Lindquist
// Twitter: @johnlindquist
let { workArea, bounds } = await getActiveScreen()
let { width, height } = workArea
let { x, y } = bounds
let padding = 100
let top = y + padding
let left = x + padding
let right = x + width - padding
let bottom = y + height - padding
setActiveAppBounds({
top,
left,
right,
bottom,
})

Select and open a bookmark from Chrome

// Menu: Chrome Bookmarks
// Description: Select and open a bookmark from Chrome
// Author: John Lindquist
// Twitter: @johnlindquist
let bookmarks = await readFile(
home(
"Library/Application Support/Google/Chrome/Default/Bookmarks"
)
)
bookmarks = JSON.parse(bookmarks)
bookmarks = bookmarks.roots.bookmark_bar.children
let url = await arg(
"Select bookmark",
bookmarks.map(({ name, url }) => {
return {
name,
description: url,
value: url,
}
})
)
exec(`open "${url}"`)

List all Chrome tabs. Then switch to that tab

// Menu: Open Chrome Tab
// Description: List all Chrome tabs. Then switch to that tab
// Author: John Lindquist
// Twitter: @johnlindquist
let currentTabs = await getTabs()
let bookmarks = await readFile(
home(
"Library/Application Support/Google/Chrome/Default/Bookmarks"
)
)
bookmarks = JSON.parse(bookmarks)
bookmarks = bookmarks.roots.bookmark_bar.children
let bookmarkChoices = bookmarks.map(({ name, url }) => {
return {
name: url,
description: name,
value: url,
}
})
let currentOpenChoices = currentTabs.map(
({ url, title }) => ({
name: url,
value: url,
description: title,
})
)
let bookmarksAndOpen = [
...bookmarkChoices,
...currentOpenChoices,
]
let choices = _.uniqBy(bookmarksAndOpen, "name")
let url = await arg("Focus Chrome tab:", choices)
focusTab(url)

List all Chrome tabs. Then switch to that tab

// Menu: Chrome Tab Switcher
// Description: List all Chrome tabs. Then switch to that tab
// Author: John Lindquist
// Twitter: @johnlindquist
let tabs = await getTabs()
let url = await arg(
"Select Chrome tab:",
tabs.map(({ url, title }) => ({
name: url,
value: url,
description: title,
}))
)
focusTab(url)

chrome-tab

by John Lindquist
Add to Kit.app+

Launch a url in Chrome. If url is already open, switch to that tab.

// Description: Launch a url in Chrome. If url is already open, switch to that tab.
// Author: John Lindquist
// Twitter: @johnlindquist
let url = await arg("Enter url:")
focusTab(url)

Converts colors between rgb, hex, etc

// Menu: Convert Colors
// Description: Converts colors between rgb, hex, etc
// Author: John Lindquist
// Twitter: @johnlindquist
let convert = await npm("color-convert")
let createChoice = (type, value, input) => {
return {
name: type + ": " + value,
value,
html: `<div class="h-full w-full p-1 text-xs flex justify-center items-center font-bold" style="background-color:${input}">
<span>${value}</span>
</div>`,
}
}
//using a function with "input" allows you to generate values
let conversion = await arg("Enter color:", input => {
if (input.startsWith("#")) {
return ["rgb", "cmyk", "hsl"].map(type => {
let value = convert.hex[type](input).toString()
return createChoice(type, value, input)
})
}
//two or more lowercase
if (input.match(/^[a-z]{2,}/)) {
return ["rgb", "hex", "cmyk", "hsl"]
.map(type => {
try {
let value =
convert.keyword[type](input).toString()
return createChoice(type, value, input)
} catch (error) {
return ""
}
})
.filter(Boolean)
}
return []
})
setSelectedText(conversion)

dev-ss

by John Lindquist
Add to Kit.app+

This probably won't run on your machine ๐Ÿ˜œ

// Menu: John's personal startup script for scriptkit.com
// Description: This probably won't run on your machine ๐Ÿ˜œ
// Author: John Lindquist
// Twitter: @johnlindquist
edit(`~/projects/scriptkit.com`)
iterm(`cd ~/projects/scriptkit.com; vercel dev`)
await focusTab("http://localhost:3000")

File Search

// Menu: Search for a File
// Description: File Search
// Author: John Lindquist
// Twitter: @johnlindquist
/** Note: This is a very basic search implementation based on "mdfind".
* File search will be a _big_ focus in future versions of Script Kit
*/
let selectedFile = await arg(
"Search a file:",
async input => {
if (input?.length < 4) return []
let files = await fileSearch(input)
return files.map(path => {
return {
name: path.split("/").pop(),
description: path,
value: path,
}
})
}
)
exec(`open ${selectedFile}`)

Launch Twitter in Chrome. If Twitter is already open, switch to that tab.

// Description: Launch Twitter in Chrome. If Twitter is already open, switch to that tab.
// Author: John Lindquist
// Twitter: @johnlindquist
// Shortcut: opt t
//runs the "chrome-tab" script with twitter.com passed into the first `arg`
await run("chrome-tab", "twitter.com")

Search giphy. Paste link.

// Menu: Giphy
// Description: Search giphy. Paste link.
// Author: John Lindquist
// Twitter: @johnlindquist
let download = await npm("image-downloader")
let queryString = await npm("query-string")
let GIPHY_API_KEY = await env("GIPHY_API_KEY", {
hint: md(
`Get a [Giphy API Key](https://developers.giphy.com/dashboard/)`
),
ignoreBlur: true,
secret: true,
})
let search = q =>
`https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_API_KEY}&q=${q}&limit=10&offset=0&rating=g&lang=en`
let { input, url } = await arg(
"Search giphy:",
async input => {
if (!input) return []
let query = search(input)
let { data } = await get(query)
return data.data.map(gif => {
return {
name: gif.title.trim() || gif.slug,
value: {
input,
url: gif.images.original.url,
},
preview: `<img src="${gif.images.downsized.url}" alt="">`,
}
})
}
)
let formattedLink = await arg("Format to paste", [
{
name: "URL Only",
value: url,
},
{
name: "Markdown Image Link",
value: `![${input}](${url})`,
},
{
name: "HTML <img>",
value: `<img src="${url}" alt="${input}">`,
},
])
setSelectedText(formattedLink)

Select a file in Finder, then create a Gist

// Menu: Gist from Finder
// Description: Select a file in Finder, then create a Gist
// Author: John Lindquist
// Twitter: @johnlindquist
let filePath = await getSelectedFile()
let file = filePath.split("/").pop()
let isPublic = await arg("Should the gist be public?", [
{ name: "No", value: false },
{ name: "Yes", value: true },
])
const body = {
files: {
[file]: {
content: await readFile(filePath, "utf8"),
},
},
}
if (isPublic) body.public = true
let config = {
headers: {
Authorization:
"Bearer " +
(await env("GITHUB_GIST_TOKEN", {
info: `Create a gist token: <a class="bg-white" href="https://github.com/settings/tokens/new">https://github.com/settings/tokens/new</a>`,
message: `Set .env GITHUB_GIST_TOKEN:`,
})),
},
}
const response = await post(
`https://api.github.com/gists`,
body,
config
)
exec(`open ` + response.data.html_url)

Create a Grid of Images

// Menu: Google Image Grid
// Description: Create a Grid of Images
// Author: John Lindquist
// Twitter: @johnlindquist
let gis = await npm("g-i-s")
await arg("Search for images:", async input => {
if (input.length < 3) return ``
let searchResults = await new Promise(res => {
gis(input, (_, results) => {
res(results)
})
})
return `<div class="flex flex-wrap">${searchResults
.map(({ url }) => `<img class="h-32" src="${url}" />`)
.join("")}</div>`
})

Enter an name, speak it back

// Menu: Hello World
// Description: Enter an name, speak it back
// Author: John Lindquist
// Twitter: @johnlindquist
let name = await arg(`What's your name?`)
say(`Hello, ${name}!`)

Show the metadata of an image

// Menu: Detect Image Width and Height
// Description: Show the metadata of an image
// Author: John Lindquist
// Twitter: @johnlindquist
let sharp = await npm("sharp")
let image = await arg("Search an image:", async input => {
if (input.length < 3) return []
let files = await fileSearch(input, { kind: "image" })
return files.map(path => {
return {
name: path.split("/").pop(),
value: path,
description: path,
}
})
})
let { width, height } = await sharp(image).metadata()
console.log({ width, height })
await arg(`Width: ${width} Height: ${height}`)

Select an image in Finder. Type option + i to resize it.

// Menu: Resize an Image
// Description: Select an image in Finder. Type option + i to resize it.
// Author: John Lindquist
// Twitter: @johnlindquist
// Shortcut: opt i
let sharp = await npm("sharp")
let imagePath = await getSelectedFile()
let width = Number(await arg("Enter width:"))
let metadata = await sharp(imagePath).metadata()
let newHeight = Math.floor(
metadata.height * (width / metadata.width)
)
let lastDot = /.(?!.*\.)/
let resizedImageName = imagePath.replace(
lastDot,
`-${width}.`
)
await sharp(imagePath)
.resize(width, newHeight)
.toFile(resizedImageName)

joke

by John Lindquist
Add to Kit.app+

Logs out a Dad Joke from icanhazdadjoke.com

// Menu: Dad Joke
// Description: Logs out a Dad Joke from icanhazdadjoke.com
// Author: John Lindquist
// Twitter: @johnlindquist
let response = await get(`https://icanhazdadjoke.com/`, {
headers: {
Accept: "text/plain",
},
})
let joke = response.data
setPanel(joke)
say(joke)

Generate a file using the current date in a specified folder

// Menu: New Journal Entry
// Description: Generate a file using the current date in a specified folder
// Author: John Lindquist
// Twitter: @johnlindquist
let { format } = await npm("date-fns")
let date = format(new Date(), "yyyy-MM-dd")
let journalPath = await env("JOURNAL_PATH")
if (!(await isDir(journalPath))) {
mkdir("-p", journalPath)
}
let journalFile = path.join(journalPath, date + ".md")
if (!(await isFile(journalFile))) {
let journalPrompt = `How are you feeling today?`
await writeFile(journalFile, journalPrompt)
}
edit(journalFile, env?.JOURNAL_PATH)

List dev projects

// Menu: Open Project
// Description: List dev projects
// Author: John Lindquist
// Twitter: @johnlindquist
let { projects, write } = await db("projects", {
projects: [
"~/.kit",
"~/projects/kitapp",
"~/projects/scriptkit.com",
],
})
onTab("Open", async () => {
let project = await arg("Open project:", projects)
edit(project)
})
onTab("Add", async () => {
while (true) {
let project = await arg(
"Add path to project:",
md(projects.map(project => `* ${project}`).join("\n"))
)
projects.push(project)
await write()
}
})
onTab("Remove", async () => {
while (true) {
let project = await arg("Open project:", projects)
let indexOfProject = projects.indexOf(project)
projects.splice(indexOfProject, 1)
await write()
}
})

Copy the current URL from your browser. Paste it at cursor.

// Menu: Paste URL
// Description: Copy the current URL from your browser. Paste it at cursor.
// Author: John Lindquist
// Twitter: @johnlindquist
let url = await getActiveTab()
await setSelectedText(url)

Generate an alliteraive, dashed project name, copies it to the clipboard, and shows a notification

// Menu: Project Name
// Description: Generate an alliteraive, dashed project name, copies it to the clipboard, and shows a notification
// Author: John Lindquist
// Twitter: @johnlindquist
let { generate } = await npm("project-name-generator")
const name = generate({
word: 2,
alliterative: true,
}).dashed
await setSelectedText(name)

Add lines to today's journal page

// Menu: Quick Thoughts
// Description: Add lines to today's journal page
// Author: John Lindquist
// Twitter: @johnlindquist
let { format } = await npm("date-fns")
let date = format(new Date(), "yyyy-MM-dd")
let thoughtsPath = await env("THOUGHTS_PATH")
let thoughtFile = path.join(thoughtsPath, date + ".md")
let firstEntry = true
let addThought = async thought => {
if (firstEntry) {
thought = `
- ${format(new Date(), "hh:mmaa")}
${thought}\n`
firstEntry = false
} else {
thought = ` ${thought}\n`
}
await appendFile(thoughtFile, thought)
}
let openThoughtFile = async () => {
let { stdout } = exec(`wc ${thoughtFile}`, {
silent: true,
})
let lineCount = stdout.trim().split(" ").shift()
edit(thoughtFile, thoughtsPath, lineCount + 1) //open with cursor at end
await wait(500)
exit()
}
if (!(await isFile(thoughtFile)))
await writeFile(thoughtFile, `# ${date}\n`)
while (true) {
let thought = await arg({
placeholder: "Thought:",
hint: `Type "open" to open journal`,
})
if (thought === "open") {
await openThoughtFile()
} else {
await addThought(thought)
}
}

read-news

by John Lindquist
Add to Kit.app+

Scrape headlines from news.google.com then pick headline to read

// Menu: Read News
// Description: Scrape headlines from news.google.com then pick headline to read
// Author: John Lindquist
// Twitter: @johnlindquist
let headlines = await scrapeSelector(
"https://news.google.com",
"h3",
el => ({
name: el.innerText,
value: el.firstChild.href,
})
)
let url = await arg("What do you want to read?", headlines)
exec(`open "${url}"`)

reddit

by John Lindquist
Add to Kit.app+

Browse Reddit from Script Kit

// Menu: Reddit
// Description: Browse Reddit from Script Kit
// Author: John Lindquist
// Twitter: @johnlindquist
let Reddit = await npm("reddit")
let envOptions = {
ignoreBlur: true,
hint: md(
`[Create a reddit app](https://www.reddit.com/prefs/apps)`
),
secret: true,
}
let reddit = new Reddit({
username: await env("REDDIT_USERNAME"),
password: await env("REDDIT_PASSWORD"),
appId: await env("REDDIT_APP_ID", envOptions),
appSecret: await env("REDDIT_APP_SECRET", envOptions),
userAgent: `ScriptKit/1.0.0 (https://scriptkit.com)`,
})
let subreddits = [
"funny",
"aww",
"dataisbeautiful",
"mildlyinteresting",
"RocketLeague",
]
subreddits.forEach(sub => {
onTab(sub, async () => {
let url = await arg(
"Select post to open:",
async () => {
let best = await reddit.get(`/r/${sub}/hot`)
return best.data.children.map(({ data }) => {
let {
title,
thumbnail,
url,
subreddit_name_prefixed,
preview,
} = data
let resolutions =
preview?.images?.[0]?.resolutions
let previewImage =
resolutions?.[resolutions?.length - 1]?.url
return {
name: title,
description: subreddit_name_prefixed,
value: url,
img: thumbnail,
...(previewImage && {
preview: md(`
![${title}](${previewImage})
### ${title}
`),
}),
}
})
}
)
exec(`open "${url}"`)
})
})

Select a file in Finder. Creates tunnel and copies link to clipboard.

// Menu: Share Selected File
// Description: Select a file in Finder. Creates tunnel and copies link to clipboard.
// Author: John Lindquist
// Twitter: @johnlindquistt
// Background: true
let ngrok = await npm("ngrok")
let handler = await npm("serve-handler")
let exitHook = await npm("exit-hook")
let http = await import("http")
let filePath = await getSelectedFile()
let symLinkName = _.last(
filePath.split(path.sep)
).replaceAll(" ", "-")
let symLinkPath = tmp(symLinkName)
console.log(`Creating temporary symlink: ${symLinkPath}`)
ln(filePath, symLinkPath)
let port = 3033
const server = http.createServer(handler)
cd(tmp())
server.listen(port, async () => {
let tunnel = await ngrok.connect(port)
let shareLink = tunnel + "/" + symLinkName
console.log(
chalk`{yellow ${shareLink}} copied to clipboard`
)
copy(shareLink)
})
exitHook(() => {
server.close()
if (test("-f", symLinkPath)) {
console.log(
`Removing temporary symlink: ${symLinkPath}`
)
exec(`rm ${symLinkPath}`)
}
})

Open the Sound prefs panel

// Menu: Open Sound Prefs
// Description: Open the Sound prefs panel
// Author: John Lindquist
// Twitter: @johnlindquist
exec(`open /System/Library/PreferencePanes/Sound.prefPane`)

Run a Script based on Speech Input

// Menu: Speak Script
// Description: Run a Script based on Speech Input
// Author: John Lindquist
// Twitter: @johnlindquist
let { scripts } = await db("scripts")
let escapedScripts = scripts.map(script => ({
name: `"${script.name.replace(/"/g, '\\"')}"`, //escape quotes
value: script.filePath,
}))
let speakableScripts = escapedScripts
.map(({ name }) => name)
.join(",")
let speech = await applescript(String.raw`
tell application "SpeechRecognitionServer"
listen for {${speakableScripts}}
end tell
`)
let script = escapedScripts.find(
script => script.name == `"${speech}"`
)
await run(script.value)

Display clipboard content at a defined rate

// Menu: Speed Reader
// Description: Display clipboard content at a defined rate
// Author: John Lindquist
// Twitter: @johnlindquist
let wpm = 1000 * (60 / (await arg('Enter words per minute:')))
let text = await paste()
text = text
.trim()
.split(' ')
.filter(Boolean)
.flatMap((sentence) => sentence.trim().split(' '))
let i = 0
let id = setInterval(() => {
setPlaceholder(` ${text[i++]}`)
if (i >= text.length) clearInterval(id)
}, wpm)

synonyms

by John Lindquist
Add to Kit.app+

List synonyms

// Menu: Synonym
// Description: List synonyms
// Author: John Lindquist
// Twitter: @johnlindquist
let synonym = await arg("Type a word", async input => {
if (!input || input?.length < 3) return []
let url = `https://api.datamuse.com/words?ml=${input}&md=d`
let response = await get(url)
return response.data.map(({ word, defs }) => {
return {
name: `${word}${defs?.[0] && ` - ${defs[0]}`}`,
value: word,
selected: `Paste ${word}`,
}
})
})
setSelectedText(synonym)

title-case

by John Lindquist
Add to Kit.app+

Converts the selected text to title case

// Menu: Title Case
// Description: Converts the selected text to title case
// Author: John Lindquist
// Twitter: @johnlindquist
let { titleCase } = await npm("title-case")
let text = await getSelectedText()
let titleText = titleCase(text)
await setSelectedText(titleText)

Change your name on twitter

// Menu: Update Twitter Name
// Description: Change your name on twitter
// Author: John Lindquist
// Twitter: @johnlindquist
let Twitter = await npm('twitter-lite')
let envOptions = {
hint: md(
`You need to [create an app](https://developer.twitter.com/en/apps) to get these keys/tokens`,
),
ignoreBlur: true,
secret: true,
}
let client = new Twitter({
consumer_key: await env('TWITTER_CONSUMER_KEY', envOptions),
consumer_secret: await env('TWITTER_CONSUMER_SECRET', envOptions),
access_token_key: await env('TWITTER_ACCESS_TOKEN_KEY', envOptions),
access_token_secret: await env('TWITTER_ACCESS_TOKEN_SECRET', envOptions),
})
let name = await arg('Enter new twitter name:')
let response = await client
.post('account/update_profile', {
name,
})
.catch((error) => console.log(error))

vocab-quiz

by John Lindquist
Add to Kit.app+

Quiz on random vocab words

// Menu: Vocab Quiz
// Description: Quiz on random vocab words
// Author: John Lindquist
// Twitter: @johnlindquist
await npm("wordnet-db")
let randomWord = await npm("random-word")
let { WordNet } = await npm("natural")
let wordNet = new WordNet()
let words = []
while (true) {
setPlaceholder(`Finding random word and definitions...`)
while (words.length < 4) {
let quizWord = randomWord()
let results = await new Promise(resolve => {
wordNet.lookup(quizWord, resolve)
})
if (results.length) {
let [{ lemma, def }] = results
words.push({ name: def, value: lemma })
}
}
let word = words[0]
let result = await arg(
`What does "${word.value}" mean?`,
_.shuffle(words)
)
let correct = word.value === result
setPlaceholder(
`${correct ? "โœ…" : "๐Ÿšซ"} ${word.value}: ${word.name}`
)
words = []
await wait(2000)
}

word-api

by John Lindquist
Add to Kit.app+

Queries a word api. Pastes selection.

// Menu: Word API
// Description: Queries a word api. Pastes selection.
// Author: John Lindquist
// Twitter: @johnlindquist
let typeMap = {
describe: "rel_jjb",
trigger: "rel_trg",
noun: "rel_jja",
follow: "lc",
rhyme: "rel_rhy",
spell: "sp",
synonym: "ml",
sounds: "rel_nry",
suggest: "suggest",
}
let word = await arg("Type a word and hit Enter:")
let typeArg = await arg(
"What would you like to find?",
Object.keys(typeMap)
)
let type = typeMap[typeArg]
word = word.replace(/ /g, "+")
let url = `https://api.datamuse.com/words?${type}=${word}&md=d`
if (typeArg == "suggest")
url = `https://api.datamuse.com/sug?s=${word}&md=d`
let response = await get(url)
let formattedWords = response.data.map(({ word, defs }) => {
let info = ""
if (defs) {
let [type, meaning] = defs[0].split("\t")
info = `- (${type}): ${meaning}`
}
return {
name: `${word}${info}`,
value: word,
}
})
let pickWord = await arg("Select to paste:", formattedWords)
setSelectedText(pickWord)
const fs = await npm("fs");
const plantName = await arg("Enter a plant name:");
const potSize = await arg("Enter pot size: ");
const shotType = await arg("Is this product or detail?", ["product", "detail"]);
const photos = await drop("Drop your images");
console.log(photos);
let renamePhotoToDirectory = (path, plantName) => {
// going to be a env variable
let targetDirectory = "/Users/zac/Desktop/photops/photos";
let photoPath = `${targetDirectory}/${plantName}`;
fs.rename(path, photoPath, (err) => {
if (err) return console.log("there was an error: ", err);
console.log("selectedFiles renamed");
});
};
photos.map((plant, index) => {
if (shotType === "detail") {
renamePhotoToDirectory(
plant.path,
`${plantName}Detail${potSize}_${index + 1}.png`
);
} else {
renamePhotoToDirectory(plant.path, `${plantName}${potSize}_${index + 1}.png`);
}
});

Upload an image to cloudinary

// Menu: Cloudinary upload
// Description: Upload an image to cloudinary
// Shortcut: command option control c
// Author: Kent C. Dodds
// Twitter: @kentcdodds
import path from 'path'
const cloudinaryCloudName = await env('CLOUDINARY_CLOUD_NAME')
const cloudinaryKey = await env('CLOUDINARY_API_KEY')
const cloudinarySecret = await env('CLOUDINARY_API_SECRET')
const cloudiaryConsoleId = await env('CLOUDINARY_CONSOLE_ID')
await npm('cloudinary')
import cloudinary from 'cloudinary'
const cacheDb = await db('cloudinary-cache', {lastChoice: '', folders: {}})
await cacheDb.read()
cloudinary.config({
cloud_name: cloudinaryCloudName,
api_key: cloudinaryKey,
api_secret: cloudinarySecret,
secure: true,
})
const actions = {
CREATE_NEW: 'creating new folder',
REFRESH_CACHE: 'refreshing cache',
OPEN_DIR: 'opening directory',
}
let chosenDirectory = await cacheDb.data.lastChoice
let lastSelection
while (true) {
// if the last action was to create a new directory then we know the chosen
// directory is new and has no folders otherwise we have to wait a few seconds
// for the API to be prepared for us to make a request for the contents.
const directories =
lastSelection === actions.CREATE_NEW
? []
: await getFolders(chosenDirectory)
lastSelection = await arg(
`Select directory in ${chosenDirectory || '/'}`,
[
{name: '.', value: '.', description: 'โœ… Choose this directory'},
!chosenDirectory
? null
: {name: '..', value: '..', description: 'โคด๏ธ Go up a directory'},
...directories.map(folder => ({
name: folder.name,
value: folder.path,
description: 'โคต๏ธ Select directory',
})),
{
name: 'Open directory',
value: actions.OPEN_DIR,
description: '๐ŸŒ Open this directory in the browser',
},
{
name: 'Refresh cache',
value: actions.REFRESH_CACHE,
description: '๐Ÿ”„ Refresh the cache for this directory',
},
{
name: 'Create new directory',
value: actions.CREATE_NEW,
description: 'โž• Create a new directory here',
},
].filter(Boolean),
)
if (lastSelection === '..') {
chosenDirectory = chosenDirectory.split('/').slice(0, -1).join('/')
} else if (lastSelection === '.') {
break
} else if (lastSelection === actions.CREATE_NEW) {
const newFolderName = await arg(`What's the new folder name?`)
const newDirectory = `${chosenDirectory}/${newFolderName}`
await cloudinary.v2.api.create_folder(newDirectory)
delete cacheDb.data.folders[chosenDirectory]
chosenDirectory = newDirectory
} else if (lastSelection === actions.REFRESH_CACHE) {
delete cacheDb.data.folders[chosenDirectory]
} else if (lastSelection === actions.OPEN_DIR) {
await openFolder(chosenDirectory)
} else {
chosenDirectory = lastSelection
}
}
cacheDb.data.lastChoice = chosenDirectory
await cacheDb.write()
const images = await drop('Drop the image(s) you want to upload')
let renameSome = true
if (images.length > 1) {
const renameChoice = await arg('Do you want to rename any of these?', [
'yes',
'no',
])
renameSome = renameChoice === 'yes'
}
for (const image of images) {
const defaultName = path.parse(image.path).name
const name = renameSome
? (await arg({
placeholder: `Name of this image?`,
hint: `Default is: "${defaultName}"`,
})) || defaultName
: defaultName
setPlaceholder(`Uploading ${name}`)
const uploadedImage = await cloudinary.v2.uploader.upload(image.path, {
public_id: name,
overwrite: false,
folder: chosenDirectory,
})
// If you have multiple files then this isn't really useful unless you have
// clipbloard history (which I recommend you get!)
await copy(uploadedImage.secure_url)
}
await openFolder(chosenDirectory)
function openFolder(folder) {
const encodedFolder = encodeURIComponent(folder)
console.log('opening')
return exec(
`open "https://cloudinary.com/console/${cloudiaryConsoleId}/media_library/folders/${encodedFolder}"`,
)
}
async function getFolders(directory) {
const cachedDirectories = cacheDb.data.folders[directory]
if (cachedDirectories) {
return cachedDirectories
}
try {
const {folders: directories} = !directory
? await cloudinary.v2.api.root_folders()
: await cloudinary.v2.api.sub_folders(directory)
cacheDb.data.folders[directory] = directories
await cacheDb.write()
return directories
} catch (error) {
console.error('error with the directory')
return []
}
}

Query convertkit

// Menu: ConvertKit > Lookup
// Description: Query convertkit
// Author: Kent C. Dodds
// Twitter: @kentcdodds
const CONVERT_KIT_API_SECRET = await env('CONVERT_KIT_API_SECRET')
const CONVERT_KIT_API_KEY = await env('CONVERT_KIT_API_KEY')
const query = await arg('query')
let url
if (query.includes('@')) {
const sub = await getConvertKitSubscriber(query)
if (sub?.id) {
url = `https://app.convertkit.com/subscribers/${sub.id}`
}
}
if (!url) {
url = `https://app.convertkit.com/subscribers?utf8=%E2%9C%93&q=${query}&status=all`
}
exec(`open "${url}"`)
async function getConvertKitSubscriber(email) {
const url = new URL('https://api.convertkit.com/v3/subscribers')
url.searchParams.set('api_secret', CONVERT_KIT_API_SECRET)
url.searchParams.set('email_address', email)
const resp = await fetch(url.toString())
const json = await resp.json()
const {subscribers: [subscriber] = []} = json
return subscriber
}

Write a quick story

// Menu: Daily Story
// Description: Write a quick story
// Author: Kent C. Dodds
// Shortcut: command option control o
// Twitter: @kentcdodds
const dateFns = await npm('date-fns')
const filenamify = await npm('filenamify')
const prettier = await npm('prettier')
const storyDir = await env(
'DAILY_STORY_DIRECTORY',
`Where do you want daily stories to be saved?`,
)
const story = await textarea({placeholder: 'Write your story here'})
const today = dateFns.format(new Date(), 'yyyy-MM-dd')
const date = await arg({
input: today,
hint: 'When did this happen?',
})
const title = await arg({
placeholder: 'What do you want to call this story?',
hint: 'Title',
})
const md = `---
title: ${title}
date: ${date}
written: ${today}
---
${story}
`
// prettify the markdown
const prettyMd = await prettier.format(md, {
parser: 'markdown',
arrowParens: 'avoid',
bracketSpacing: false,
embeddedLanguageFormatting: 'auto',
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxBracketSameLine: false,
jsxSingleQuote: false,
printWidth: 80,
proseWrap: 'always',
quoteProps: 'as-needed',
requirePragma: false,
semi: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
vueIndentScriptAndStyle: false,
})
const filename = filenamify(
`${date}-${title.toLowerCase().replace(/ /g, '-')}.md`,
{replacement: '-'},
)
await writeFile(path.join(storyDir, filename), prettyMd)

new-post

by Kent C. Dodds
Add to Kit.app+

Create a new blog post

// Menu: New Post
// Description: Create a new blog post
// Author: Kent C. Dodds
// Shortcut: command option control p
// Twitter: @kentcdodds
const dateFns = await npm('date-fns')
const prettier = await npm('prettier')
const YAML = await npm('yaml')
const slugify = await npm('@sindresorhus/slugify')
const {format: formatDate} = await npm('date-fns')
const makeMetascraper = await npm('metascraper')
const {$filter, toRule} = await npm('@metascraper/helpers')
const unsplashTitleToAlt = toRule(str => str.replace(/ photo โ€“ .*$/, ''))
const unsplashOGTitleToAuthor = toRule(str =>
str.replace(/Photo by (.*?) on Unsplash/, '$1'),
)
const unsplashImageToPhotoId = toRule(str =>
new URL(str).pathname.replace('/', ''),
)
const metascraper = makeMetascraper([
{
unsplashPhotoId: [
unsplashImageToPhotoId($ =>
$('meta[property="og:image"]').attr('content'),
),
],
},
{
author: [
unsplashOGTitleToAuthor($ =>
$('meta[property="og:title"]').attr('content'),
),
],
},
{alt: [unsplashTitleToAlt($ => $('title').text())]},
])
async function getMetadata(url) {
const html = await fetch(url).then(res => res.text())
return metascraper({html, url})
}
const blogDir = await env(
'KCD_BLOG_CONTENT_DIR',
`What's the path to the blog content directory on this machine?`,
)
const title = await arg({
placeholder: `What's the title of this post?`,
hint: 'Title',
ignoreBlur: true,
})
const description = await arg({
placeholder: `What's the description of this post?`,
hint: 'Description',
input: 'TODO: add a description',
ignoreBlur: true,
})
const categories = (
await arg({
placeholder: `What are the categories of this post?`,
hint: 'Categories (comma separated)',
ignoreBlur: true,
})
)
.split(',')
.map(c => c.trim())
.filter(Boolean)
const keywords = (
await arg({
placeholder: `What are the keywords of this post?`,
hint: 'Keywords (comma separated)',
ignoreBlur: true,
})
)
.split(',')
.map(c => c.trim())
.filter(Boolean)
const filename = slugify(title, {decamelize: false})
await exec(`open https://unsplash.com/s/photos/${filename}`)
const unsplashPhotoInput = await arg({
placeholder: `What's the unsplash photo?`,
hint: 'Unsplash Photo',
ignoreBlur: true,
})
const unsplashPhotoUrl = unsplashPhotoInput.startsWith('http')
? unsplashPhotoInput
: `https://unsplash.com/photos/${unsplashPhotoInput}`
const metadata = await getMetadata(unsplashPhotoUrl)
const frontmatter = YAML.stringify({
title,
date: dateFns.format(new Date(), 'yyyy-MM-dd'),
description,
categories,
meta: {keywords},
bannerCloudinaryId: `unsplash/${metadata.unsplashPhotoId}`,
bannerAlt: metadata.alt,
bannerCredit: `Photo by [${metadata.author}](${unsplashPhotoUrl})`,
})
const md = `---
${frontmatter}
---
Be excellent to each other.
`
// prettify the markdown
const prettyMd = await prettier.format(md, {
parser: 'markdown',
arrowParens: 'avoid',
bracketSpacing: false,
embeddedLanguageFormatting: 'auto',
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxBracketSameLine: false,
jsxSingleQuote: false,
printWidth: 80,
proseWrap: 'always',
quoteProps: 'as-needed',
requirePragma: false,
semi: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
vueIndentScriptAndStyle: false,
})
const newFile = path.join(blogDir, `${filename}.mdx`)
await writeFile(newFile, prettyMd)
await edit(newFile)

Open all the EpicReact Repos in VSCode

// Menu: Open EpicReact Repos
// Description: Open all the EpicReact Repos in VSCode
const repos = [
'advanced-react-hooks',
'advanced-react-patterns',
'bookshelf',
'react-fundamentals',
'react-hooks',
'react-performance',
'react-suspense',
'testing-react-apps',
]
for (const repo of repos) {
edit(`~/code/epic-react/${repo}`)
}

Opens a project in code

// Menu: Open Project
// Description: Opens a project in code
// Shortcut: cmd shift .
import path from 'path'
import fs from 'fs'
import os from 'os'
const isDirectory = async filePath => {
try {
const stat = await fs.promises.stat(filePath)
return stat.isDirectory()
} catch (e) {
return false
}
}
const isFile = async filePath => {
try {
const stat = await fs.promises.stat(filePath)
return stat.isFile()
} catch (e) {
return false
}
}
async function getProjects(parentDir) {
const codeDir = (await ls(parentDir)).stdout.split('\n').filter(Boolean)
const choices = []
for (const dir of codeDir) {
let fullPath = dir
if (!path.isAbsolute(dir)) {
fullPath = path.join(parentDir, dir)
}
if (fullPath.includes('/node_modules/')) continue
if (fullPath.includes('/build/')) continue
if (fullPath.includes('/dist/')) continue
if (fullPath.includes('/coverage/')) continue
const pkgjson = path.join(fullPath, 'package.json')
if (await isFile(pkgjson)) {
choices.push({
name: dir,
value: fullPath,
description: fullPath,
})
} else if (await isDirectory(fullPath)) {
choices.push(...(await getProjects(fullPath)))
}
}
return choices
}
const choice = await arg('Which project?', async () => {
const choices = [
...(await getProjects(path.join(os.homedir(), 'code'))),
...(await getProjects(path.join(os.homedir(), 'Desktop'))),
]
return choices
})
await edit(choice)

shorten

by Kent C. Dodds
Add to Kit.app+

Shorten a given URL with a given short name via netlify-shortener

// Menu: Shorten
// Description: Shorten a given URL with a given short name via netlify-shortener
// Shortcut: command option control s
// Author: Kent C. Dodds
// Twitter: @kentcdodds
const dir = await env(
'SHORTEN_REPO_DIRECTORY',
'Where is your netlify-shortener repo directory?',
)
const longURL = await arg(`What's the full URL?`)
// TODO: figure out how to make this optional
const shortName = await arg(`What's the short name?`)
const netlifyShortenerPath = path.join(
dir,
'node_modules/netlify-shortener/dist/index.js',
)
const {baseUrl} = JSON.parse(await readFile(path.join(dir, 'package.json')))
setPlaceholder(`Creating redirect: ${baseUrl}/${shortName} -> ${longURL}`)
const result = exec(
`node "${netlifyShortenerPath}" "${longURL}" "${shortName}"`,
)
const {stderr, stdout} = result
if (result.code === 0) {
const lastLine = stdout.split('\n').filter(Boolean).slice(-1)[0]
notify({
title: 'โœ… Short URL created',
message: lastLine,
})
} else {
const getErr = str => str.match(/Error: (.+)\n/)?.[1]
const error = getErr(stderr) ?? getErr(stdout) ?? 'Unknown error'
console.error({stderr, stdout})
notify({
title: 'โŒ Short URL not created',
message: error,
})
}

Download twitter images and set their exif info based on the tweet metadata

// Menu: Twimage Download
// Description: Download twitter images and set their exif info based on the tweet metadata
// Shortcut: command option control t
// Author: Kent C. Dodds
// Twitter: @kentcdodds
import fs from 'fs'
import {fileURLToPath, URL} from 'url'
const exiftool = await npm('node-exiftool')
const exiftoolBin = await npm('dist-exiftool')
const fsExtra = await npm('fs-extra')
const baseOut = home('Pictures/twimages')
const token = await env('TWITTER_BEARER_TOKEN')
const twitterUrl = await arg('Twitter URL')
console.log(`Starting with ${twitterUrl}`)
const tweetId = new URL(twitterUrl).pathname.split('/').slice(-1)[0]
const response = await get(
`https://api.twitter.com/1.1/statuses/show/${tweetId}.json?include_entities=true`,
{
headers: {
authorization: `Bearer ${token}`,
},
},
)
const tweet = response.data
console.log({tweet})
const {
geo,
id,
text,
created_at,
extended_entities: {media: medias} = {
media: [
{
type: 'photo',
media_url_https: await arg({
ignoreBlur: true,
input: `Can't find media. What's the URL for the media?`,
hint: `Media URL`,
}),
},
],
},
} = tweet
const [latitude, longitude] = geo?.coordinates || []
const ep = new exiftool.ExiftoolProcess(exiftoolBin)
await ep.open()
for (const media of medias) {
let url
if (media.type === 'photo') {
url = media.media_url_https
} else if (media.type === 'video') {
let best = {bitrate: 0}
for (const variant of media.video_info.variants) {
if (variant.bitrate > best.bitrate) best = variant
}
url = best.url
} else {
throw new Error(`Unknown media type for ${twitterUrl}: ${media.type}`)
}
if (!url) throw new Error(`Huh... no media url found for ${twitterUrl}`)
const formattedDate = formatDate(created_at)
const colonDate = formattedDate.replace(/-/g, ':')
const formattedTimestamp = formatTimestamp(created_at)
const filename = new URL(url).pathname.split('/').slice(-1)[0]
const filepath = path.join(
baseOut,
formattedDate.split('-').slice(0, 2).join('-'),
/\..+$/.test(filename) ? filename : `${filename}.jpg`,
)
await download(url, filepath)
await ep.writeMetadata(
filepath,
{
ImageDescription: `${text} โ€“ ${twitterUrl}`,
Keywords: 'photos from tweets',
DateTimeOriginal: formattedTimestamp,
FileModifyDate: formattedTimestamp,
ModifyDate: formattedTimestamp,
CreateDate: formattedTimestamp,
...(geo
? {
GPSLatitudeRef: latitude > 0 ? 'North' : 'South',
GPSLongitudeRef: longitude > 0 ? 'East' : 'West',
GPSLatitude: latitude,
GPSLongitude: longitude,
GPSDateStamp: colonDate,
GPSDateTime: formattedTimestamp,
}
: null),
},
['overwrite_original'],
)
}
await ep.close()
notify(`All done with ${twitterUrl}`)
function formatDate(t) {
const d = new Date(t)
return `${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(
d.getDate(),
)}`
}
function formatTimestamp(t) {
const d = new Date(t)
const formattedDate = formatDate(t)
return `${formatDate(t)} ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`
}
function padZero(n) {
return String(n).padStart(2, '0')
}
async function getGeoCoords(placeId) {
const response = await get(
`https://api.twitter.com/1.1/geo/id/${placeId}.json`,
{
headers: {
authorization: `Bearer ${token}`,
},
},
)
const [longitude, latitude] = response.data.centroid
return {latitude, longitude}
}
async function download(url, out) {
console.log(`downloading ${url} to ${out}`)
await fsExtra.ensureDir(path.dirname(out))
const writer = fs.createWriteStream(out)
const response = await get(url, {responseType: 'stream'})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(out))
writer.on('error', reject)
})
}

Update all the dependencies in the epicreact workshop repos

// Menu: Update EpicReact deps
// Description: Update all the dependencies in the epicreact workshop repos
const repos = [
'advanced-react-hooks',
'advanced-react-patterns',
'bookshelf',
'react-fundamentals',
'react-hooks',
'react-performance',
'react-suspense',
'testing-react-apps',
]
const script = `git add -A && git stash && git checkout main && git pull && ./scripts/update-deps && git commit -am "update all deps" --no-verify && git push && git status`
for (const repo of repos) {
const scriptString = JSON.stringify(
`cd ~/code/epic-react/${repo} && ${script}`,
)
exec(
`osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script ${scriptString}'`,
)
}