gn (Get Notes) is a zero-dependency CLI
note utility that saves your markdown files directly
to a private GitHub repository. It acts as a
lightweight sync layer using nothing but native Bash
and curl - no local Git installation or setup
required.
When you run gn note-name, it instantly
pulls the latest file version from GitHub, opens it
in your default $EDITOR, and
automatically pushes your changes via the GitHub API
as soon as you save and exit.
It runs completely in the foreground with zero background daemons, no local databases, and no tracking. It is built to give you seamless cloud backups while keeping your notes as raw, standalone text files under your own control.
Before installing gn ensure your Github
environment is set up.
You will need a GitHub account and a GitHub personal access token.
Set Expiration to "No expiration." Under Select scope check "repo" (Full control of private repositories.) Generate the token. NOTE: Copy the token now - it is only displayed once. You'll need it in the configuration setup step below.
Log into GitHub, click
New Repository, and name it
gn. Ensure you explicitly change
visibility to Private so your
data stays hidden. Do not setup with a README,
license, or .gitignore layout template.
Download the install script or manually configure
gn using the directions below.
Download an install.sh that prompts you for
your GitHub credentials during installation and fully
sets up gn for you.
Make Executable And Run
chmod +x install.sh && ./install.sh
#!/usr/bin/env bash
# --- Configuration ---
NOTES_DIR="$HOME/gn"
# --- Load Config ---
CONFIG_FILE="$NOTES_DIR/gn.conf"
if [ -f "$CONFIG_FILE" ]; then
. "$CONFIG_FILE"
else
echo "Error: No config found at $CONFIG_FILE"
echo "Create it with:"
echo " GH_TOKEN=yourtoken"
echo " GH_OWNER=yourusername"
echo " GH_REPO=yourrepo"
exit 1
fi
GH_API="https://api.github.com/repos/$GH_OWNER/$GH_REPO/contents"
mkdir -p "$NOTES_DIR"
cd "$NOTES_DIR" || { echo "Error: Could not access $NOTES_DIR"; exit 1; }
# --- Secure curl wrapper: token passed via tempfile, never exposed in ps ---
gh_curl() {
local hdr
hdr=$(mktemp)
echo "Authorization: token $GH_TOKEN" > "$hdr"
chmod 600 "$hdr"
curl -s -H "@$hdr" "$@"
local rc=$?
rm -f "$hdr"
return $rc
}
# --- GitHub API Sync Functions ---
pull_from_github() {
local file="$1"
local response content http_code
response=$(gh_curl -w "\n%{http_code}" "$GH_API/$file")
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | head -n -1)
if [ "$http_code" = "404" ]; then
return 0
fi
if [ "$http_code" != "200" ]; then
echo "Error: pull failed (HTTP $http_code). Check your token and repo name." >&2
exit 1
fi
content=$(echo "$response" | grep ''"content"' | sed 's/.*"content": *"\(.*\)".*/\1/' | tr -d '\\n')
if [ -n "$content" ]; then
echo "$content" | base64 -d > "$file" 2>/dev/null || echo "$content" | base64 -D > "$file"
fi
}
push_to_github() {
local file="$1"
local sha content msg api_url sha_response push_response http_code
api_url="$GH_API/$file"
sha_response=$(gh_curl "$api_url")
sha=$(echo "$sha_response" | grep '"sha"' | head -1 | sed 's/.*"sha": *"\([^"]*\)".*/\1/')
content=$(base64 -w0 < "$file" 2>/dev/null || base64 < "$file" | tr -d '\n')
msg="Note update: $file on $(date '+%Y-%m-%d %H:%M:%S')"
local sha_field=""
[ -n "$sha" ] && sha_field=",\"sha\":\"$sha\""
push_response=$(gh_curl -w "\n%{http_code}" -X PUT "$api_url" \
-d "{\"message\":\"$msg\",\"content\":\"$content\"$sha_field}")
http_code=$(echo "$push_response" | tail -1)
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
echo "Error: push failed (HTTP $http_code). Your note was saved locally but not synced." >&2
exit 1
fi
}
# --- Help Text Function ---
show_help() {
echo "Usage: gn [options] [note_name]"
echo ""
echo "Options:"
echo " -h Show this help message"
echo " -l List all notes in your notes directory"
echo " -g QUERY Search for text across all notes (grep)"
echo " -t Quickly open today's journal note (YYYY-MM-DD.md)"
echo " -d NOTE Delete a note locally and from GitHub"
echo " -r OLD NEW Rename a note locally and on GitHub"
echo " -p Pull all notes from GitHub to local"
echo ""
echo "Examples:"
echo " gn Opens index.md"
echo " gn log Creates log.md"
echo " gn log Opens log.md"
echo " gn work/todo Opens work/todo.md"
echo " gn -g 'api key' Searches all notes for the term 'api key'"
echo " gn -t Creates a note named today's date"
echo " gn -p Pulls all notes from GitHub to local"
exit 0
}
# --- List Files Function ---
list_notes() {
echo "Current Notes in $NOTES_DIR:"
if [ -d "$NOTES_DIR" ]; then
find . -type f -not -name "gn.conf" -not -path '*/.*' | sed 's|^\./||' | sort
fi
exit 0
}
# --- Search Inside Notes Function ---
search_notes() {
echo "Searching for '$1' inside notes..."
grep -Rin "$1" . --exclude-dir=".git" --exclude="gn.conf"
exit 0
}
# --- Delete Note Function ---
delete_note() {
local file="$1"
if [[ "$file" != *.md ]]; then
file="${file}.md"
fi
if [ ! -f "$NOTES_DIR/$file" ]; then
echo "Error: '$file' not found locally."
exit 1
fi
read -r -p "Delete '$file'? This cannot be undone. [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
local sha api_url
api_url="$GH_API/$file"
sha=$(gh_curl "$api_url" | grep '"sha"' | head -1 | sed 's/.*"sha": *"\([^"]*\)".*/\1/')
if [ -n "$sha" ]; then
gh_curl -X DELETE "$api_url" \
-d "{\"message\":\"Delete $file\",\"sha\":\"$sha\"}" > /dev/null
echo "Deleted from GitHub."
else
echo "Warning: File not found on GitHub. Removing locally only."
fi
rm "$NOTES_DIR/$file"
echo "Deleted '$file'."
exit 0
}
# --- Rename Note Function ---
rename_note() {
local old_name="$1"
local new_name="$2"
if [[ "$old_name" != *.md ]]; then
old_name="${old_name}.md"
fi
if [[ "$new_name" != *.md ]]; then
new_name="${new_name}.md"
fi
if [ ! -f "$NOTES_DIR/$old_name" ]; then
echo "Error: '$old_name' not found locally."
exit 1
fi
if [ -f "$NOTES_DIR/$new_name" ]; then
echo "Error: '$new_name' already exists."
exit 1
fi
local sha old_api_url new_api_url content
old_api_url="$GH_API/$old_name"
new_api_url="$GH_API/$new_name"
sha=$(gh_curl "$old_api_url" | grep '"sha"' | head -1 | sed 's/.*"sha": *"\([^"]*\)".*/\1/')
content=$(base64 -w0 < "$NOTES_DIR/$old_name" 2>/dev/null || base64 < "$NOTES_DIR/$old_name")
# Create new file on GitHub
gh_curl -X PUT "$new_api_url" \
-d "{\"message\":\"Rename $old_name to $new_name\",\"content\":\"$content\"}" > /dev/null
# Delete old file on GitHub
if [ -n "$sha" ]; then
gh_curl -X DELETE "$old_api_url" \
-d "{\"message\":\"Rename $old_name to $new_name\",\"sha\":\"$sha\"}" > /dev/null
fi
mv "$NOTES_DIR/$old_name" "$NOTES_DIR/$new_name"
echo "Renamed '$old_name' to '$new_name'."
exit 0
}
# --- Pull All Notes From GitHub ---
pull_all_from_github() {
local response http_code files file
echo "Fetching file list from GitHub..."
response=$(gh_curl -w "\n%{http_code}" "$GH_API")
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | head -n -1)
if [ "$http_code" != "200" ]; then
echo "Error: could not list remote files (HTTP $http_code). Check your token and repo name." >&2
exit 1
fi
files=$(echo "$response" | grep '"name"' | sed 's/.*"name": *"\([^"]*\)".*/\1/')
if [ -z "$files" ]; then
echo "No notes found in remote repo."
exit 0
fi
local count=0
while IFS= read -r file; do
[[ "$file" == *.md ]] || continue
echo " pulling $file..."
pull_from_github "$file"
count=$((count + 1))
done <<< "$files"
echo "Done. $count note(s) synced locally."
exit 0
}
# --- Parse Flags ---
while getopts "hlg:td:r:p" opt; do
case ${opt} in
h ) show_help ;;
l ) list_notes ;;
g ) search_notes "$OPTARG" ;;
t ) NOTE_NAME=$(date '+%Y-%m-%d') ;;
d ) delete_note "$OPTARG" ;;
r ) rename_note "$OPTARG" "${@:$OPTIND:1}" ;;
p ) pull_all_from_github ;;
\? ) show_help ;;
esac
done
shift $((OPTIND -1))
# If -t wasn't passed, get note name from command line arguments
if [ -z "$NOTE_NAME" ]; then
NOTE_NAME="${1:-index}"
fi
if [[ "$NOTE_NAME" == "gn.conf" ]]; then
echo "Error: Protection rule triggered. Cannot edit configuration file via gn script loop."
exit 1
fi
if [[ "$NOTE_NAME" != *.md ]]; then
NOTE_NAME="${NOTE_NAME}.md"
fi
NOTE_DIR_PATH=$(dirname "$NOTE_NAME")
if [ "$NOTE_DIR_PATH" != "." ]; then
mkdir -p "$NOTE_DIR_PATH"
fi
# --- Sync From Cloud ---
echo "Fetching latest cloud updates..."
pull_from_github "$NOTE_NAME"
# --- Open the Editor ---
${EDITOR:-nano} "$NOTE_NAME"
# --- Sync Back to GitHub ---
echo "Syncing changes to GitHub..."
push_to_github "$NOTE_NAME"
echo "Sync complete!"
If you prefer not to use the automated
install.sh script, follow these steps to
configure gn manually:
The config file lives inside your gn directory as a .conf file. Create the directory first, then the config:
mkdir -p ~/gn
nano ~/gn/gn.conf
chmod 600 ~/gn/gn.conf
Add these three lines, substituting your Github credentials:
GH_TOKEN=ghp_yourpersonalaccesstoken
GH_OWNER=GITHUB-USERNAME
GH_REPO=gn
gn).
Make the downloaded gn.sh script
executable and copy it into your
/usr/local/bin) so it's accessible
anywhere:
chmod +x gn.sh
sudo cp gn.sh /usr/local/bin/gn
Run gn from your Terminal. Use flags for advanced features.
gn # Opens your main index.md
gn daily-log # Creates/Opens daily-log.md
gn work/reminders # Creates work/ directory and opens reminders.md
gn -h # Displays help page
gn -l # Lists all your notes
gn -g "todo" # Finds text matching "todo" inside any note
gn -t # Opens today's automated scratchpad entry
gn -d daily-log # Deletes daily-log.md locally and from GitHub
gn -r old new # Renames old.md to new.md locally and on GitHub
gn -p # Pulls all notes from GitHub (first-time setup)
Your notes live in GitHub and sync via the API, so there's nothing to clone. Just create the directory, drop in your config file, and install the script.
mkdir -p ~/gn
nano ~/gn/gn.conf
GH_TOKEN=ghp_yourpersonalaccesstoken
GH_OWNER=GITHUB-USERNAME
GH_REPO=gn
Download gn.sh using the button
above and install it:
chmod +x gn.sh
sudo cp gn.sh /usr/local/bin/gn
That's it. Run gn -p to pull all
your existing notes down at once, then use
gn normally from there.
If you're on a machine without a terminal - or just want a quick edit from any browser - you can use github.dev, a full VS Code instance that runs entirely in the browser and commits directly to your repo.
Navigate to your gn repo on GitHub,
then press . (period) on your
keyboard. The page reloads as a VS Code editor
with all your notes in the file tree on the
left.
Or just swap github.com for
github.dev in the URL directly:
https://github.dev/YOUR-USERNAME/gn
Open any .md file and make your
edits. When done, open the Source Control panel
(Ctrl+Shift+G /
Cmd+Shift+G), enter a commit
message, and click the checkmark. The change is
pushed to your repo immediately.
The next time you run gn on any
note, it pulls the latest version from GitHub
before opening the editor - no manual sync
needed.
Note: gn syncs
per-file, so if you edit the same note in
github.dev and in the terminal at the same time,
the last push wins. Avoid editing the same note
simultaneously from two places.