gn
get_notes.md

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.

bash - 80×24
user@terminal:~$
GNU nano 7.2 - hello.md
user@terminal:~$ gn hello
user@terminal:~$ gn hello
Fetching latest cloud updates...
Syncing changes to GitHub...
Sync complete!
user@terminal:~$
Usage: gn [options] [note_name]

Options:
  -h        Show this help message
  -l        List all notes in your notes directory
  -g QUERY  Search for text across all notes (grep)
  -t        Quickly open today's journal note (YYYY-MM-DD.md)
  -d NOTE   Delete a note locally and from GitHub
  -r OLD NEW Rename a note locally and on GitHub
  -p        Pull all notes from GitHub to local

Examples:
  gn                  Opens index.md
  gn log              Creates log.md
  gn work/todo        Opens work/todo.md
user@terminal:~$

Prerequisites:

Before installing gn ensure your Github environment is set up.

A GitHub Account Setup

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.

B Create a Private GitHub Repository

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 gn

Download the install script or manually configure gn using the directions below.

Option A: Installer (Recommended)

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

Option B: Manual Set Up

#!/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!"

Manual Installation

If you prefer not to use the automated install.sh script, follow these steps to configure gn manually:

1 Create the Config File

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
  • GH_TOKEN - your GitHub personal access token (classic or fine-grained) with repo permissions.
  • GH_OWNER - your explicit GitHub context username profile handle.
  • GH_REPO - the name of the private destination repository (defaults to gn).
2 Make the Script Available Globally

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
3 Using 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)

Setting Up a Second Computer

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.

1 Create the notes directory and config file
mkdir -p ~/gn
nano ~/gn/gn.conf
GH_TOKEN=ghp_yourpersonalaccesstoken
GH_OWNER=GITHUB-USERNAME
GH_REPO=gn
2 Install 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.

Editing Notes in the Browser

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.

1 Open your repo in github.dev

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
2 Edit and commit your note

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.

3 Pull before your next terminal session

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.