Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This directory is the single source of truth for reusable agent skills.

| Skill | Description |
| ---------------- | ----------- |
| `apple-notes` | Create folders and notes in Apple Notes with AppleScript, including HTML-formatted bodies and… |
| `apple-notes` | Create, move, and rename folders and notes in Apple Notes with AppleScript, including… |
| `do-nothing-script` | Turn a manual, repeated procedure into a do-nothing script (Dan Slimmon's gradual automation… |
| `dotfiles` | Apply this dotfiles repository's configuration and installation conventions. Use when changing… |
| `johnny-decimal` | Organize files in ~/Documents with the Johnny Decimal and PARA hybrid system. Use when naming,… |
Expand Down
76 changes: 71 additions & 5 deletions .agents/skills/apple-notes/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: apple-notes
description: >
Create folders and notes in Apple Notes with AppleScript, including HTML-formatted bodies and
nested folders. Use when adding, writing, or filing content into Apple Notes from the command
line. Make sure to use this skill whenever a request mentions creating a note, a Notes folder,
Create, move, and rename folders and notes in Apple Notes with AppleScript, including
HTML-formatted bodies and nested folders. Use when adding, writing, filing, or triaging content in
Apple Notes from the command line. Make sure to use this skill whenever a request mentions
creating a note, a Notes folder, processing the Notes inbox, moving or retitling an existing note,
saving something "in my Notes", or scripting Notes with osascript, even if AppleScript is never
named — the apple-notes MCP server is read-only and cannot write.
license: MIT
Expand Down Expand Up @@ -35,13 +36,17 @@ anything into them.
```bash
.agents/skills/apple-notes/scripts/notes.sh folder <name> [account]
.agents/skills/apple-notes/scripts/notes.sh note <folder> <title> [account] # HTML body on stdin
.agents/skills/apple-notes/scripts/notes.sh move <src/path> <title> <dst/path> [new-title] [account]
```

- `account` (optional): defaults to `iCloud`. List accounts with
`osascript -e 'tell application "Notes" to get name of accounts'`.
- `move` takes slash-separated nested paths, creates the missing destination levels, refuses to move
out of a shared folder, and — with `new-title` — renames the note as it files it.
- Examples:
- `notes.sh folder Recipes` → creates `Recipes` if missing (idempotent)
- `printf '<div>Buy milk</div>' | notes.sh note Recipes 'Shopping list'`
- `notes.sh move Notes 'Bigfoot : Recrutement : Template' '3 Resources/Recrutement' 'Recrutement : Template email de refus'`

## Steps

Expand All @@ -64,8 +69,42 @@ anything into them.
osascript -e 'tell application "Notes" to tell account "iCloud" to get name of notes of folder "Recipes"'
```

5. **For anything the script does not cover** (nested folders, moving, updating an existing note),
write the AppleScript inline — see the patterns below.
5. **Filing a note out of the inbox** (the default `Notes` folder) — use `notes.sh move` and pass a
`new-title`: a title has to survive search, where the folder is invisible, so name it
`Domain : Subject` matching the destination folder and drop stale prefixes (see the
`para-organizer` skill). Add the objective/state/date header by hand before moving.

`Domain : Subject` is two segments, and the segment count mirrors the destination's depth: a note
in `2 Areas/Septeo` is `Septeo : Subject`, and only a three-level destination like
`20-29 - Areas/21 - Septeo/21.08 - Recrutement` earns the legacy three-segment
`Septeo : Recrutement : Subject`. Copying a legacy title's shape into a two-level PARA folder is
what makes a batch of freshly filed notes look inconsistent.

The `Subject` is the actual topic, not the shape of the note: no generic label like `Meeting`,
`Produit`, or `Points`, no attendee name standing in for the content, and no trailing date —
Notes already stores the creation date, and a date in the title only pushes the searchable words
past the truncation point. Keep the whole title under ~66 characters: beyond that Notes truncates
the name shown in the list with an ellipsis, and that truncated string is the note's real name for
AppleScript lookups.

6. **For anything the script does not cover** (updating an existing note) write the AppleScript
inline — see the patterns below.

### Triaging one inbox note

The inbox is the default `Notes` folder. Process it oldest-first, one note per round:

1. Read the oldest note with the MCP server — `list_notes` with `folder: "Notes"`,
`sort: "date-created"`, then re-query the target with `title_contains` and
`include_content: true`. Ignore rows whose `folderName` is not exactly `Notes`: the folder filter
is a partial match.
2. Decide the destination among `1 Projects`, `2 Areas`, `3 Resources`, `4 Archives` — never a legacy
numbered folder — plus a subfolder named after the domain. Reuse an existing subfolder when one
fits; ask when the call is genuinely ambiguous.
3. Prepend a one-line header (objective, state, next step, date) and mark unknowns `???` rather than
inventing them.
4. File it with `notes.sh move Notes '<title>' '<dst/path>' '<Domain : Subject>'`.
5. Report the destination and the remaining inbox count, then stop — one note per round.

### Nested folders

Expand All @@ -92,6 +131,33 @@ end tell
body's first line and *also* prepends the `name:` value, so the title appears twice inside the
note. Put the title as the first line of the body (`<div><h1>Title</h1></div>`), which is what
`notes.sh note` does.
- **Renaming a note means rewriting the body's first line, and only that** — `set name of n` renames
the list entry, but doing it on top of a body rewrite blanks that first line, and doing it alone
leaves the body contradicting the title. Notes also converts the `<h1>` into
`<b><span style="font-size: 24px">…</span></b>` on save, so replace the whole first `<div>…</div>`
block, not the tag. `notes.sh move` does this.
- **`get body` omits attachments, so any `set body` round-trip destroys them** — a note reporting
`attachmentCount: 1` returns HTML with no trace of the image, and writing that HTML back deletes it
for good (the note lands in Recently Deleted attachment-less, so there is nothing to restore).
Check `get count of attachments of note …` before rewriting a body; `notes.sh move` now refuses to
retitle a note that has any, so rename those from the app instead.
- **A body-rewrite pipeline that fails writes an empty body and wipes the note, with no error** —
`set body of n to ""` succeeds, the note keeps its place but loses everything and shows up as
`New Note` in the list (the title lives in the body's first line). Always check the rewritten HTML
is non-empty before writing it back; `notes.sh` now refuses the write instead. Recovery means
retyping the content, so read the note before rewriting it.
- **A header has to go on line 2, never line 1** — retitling replaces the body's whole first
`<div>…</div>` block, so a header prepended above the title is silently eaten by the next retitle.
Move and retitle first, then insert the header after the title block.
- **A link attachment exposes its target via `get URL of`** — `get URL of attachment 1 of note …`
returns the shared URL, so a note whose only attachment is a Safari link preview can be rewritten
as a plain `<a href="…">` and then retitled. The blanket retitle refusal above is deliberate but
conservative: recover the URL by hand first, and only then rewrite the body.
- **A prefix env assignment in front of a pipeline only reaches the first command** —
`VAR=x osascript … | perl -e '…$ENV{VAR}…'` gives perl an empty value, which silently produces an
empty insertion rather than an error. `export` it instead; `notes.sh` does this at its retitle step.
- **A note cannot be addressed by the id the MCP server reports** — `note id "x-coredata://…/p3662"`
fails with `-1728`. Address notes by name within their folder.
- **Literal newlines break AppleScript string literals** — a multi-line HTML body inlined into an
`osascript` heredoc is a syntax error. `notes.sh` strips newlines (HTML ignores them); do the same
in hand-written scripts, or concatenate with `& return &`.
Expand Down
77 changes: 75 additions & 2 deletions .agents/skills/apple-notes/scripts/notes.sh
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
#!/usr/bin/env bash
# Create folders and notes in Apple Notes via AppleScript.
# Create, file, and rename folders and notes in Apple Notes via AppleScript.
#
# notes.sh folder <name> [account]
# notes.sh note <folder> <title> [account] # HTML body on stdin
# notes.sh note <folder> <title> [account] # HTML body on stdin
# notes.sh move <src/path> <title> <dst/path> [new-title] [account]
set -euo pipefail

usage() {
cat >&2 <<'EOF'
usage:
notes.sh folder <name> [account]
notes.sh note <folder> <title> [account] # HTML body read from stdin
notes.sh move <src/path> <title> <dst/path> [new-title] [account]
# nested paths as "3 Resources/Recrutement";
# new-title also rewrites the body's <h1>
EOF
exit 64
}

# AppleScript string literals: escape backslash then double quote.
as_quote() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }

# Turn "A/B/C" into the AppleScript specifier `folder "C" of folder "B" of folder "A"` in $spec,
# plus the `make new folder` lines for the missing levels in $mklines. Sets globals (not stdout)
# because a command substitution would run in a subshell and lose $mklines.
folder_spec() {
local q part mk
spec=""
mklines=""
local IFS=/
for part in $1; do
q=$(as_quote "$part")
if [ -z "$spec" ]; then
mk="if not (exists folder \"$q\") then make new folder with properties {name:\"$q\"}"
spec="folder \"$q\""
else
mk="if not (exists folder \"$q\" of $spec) then make new folder at $spec with properties {name:\"$q\"}"
spec="folder \"$q\" of $spec"
fi
mklines+=" $mk"$'\n'
done
}

cmd=${1:-}; shift || usage

case "$cmd" in
Expand Down Expand Up @@ -46,6 +71,54 @@ tell application "Notes" to tell account "$account"
set n to make new note at folder "$folder" with properties {body:"<div><h1>$title</h1></div>" & "$body"}
return name of n
end tell
EOF
;;
move)
[ $# -ge 3 ] || usage
title=$(as_quote "$2")
new_title=${4:-}
account=$(as_quote "${5:-iCloud}")
folder_spec "$1"; src=$spec
folder_spec "$3"; dst=$spec; dst_mk=$mklines
osascript <<EOF
tell application "Notes" to tell account "$account"
if (get shared of $src) then error "refusing to move a note out of a shared folder — sharing cannot be restored by script"
$dst_mk
move note "$title" of $src to $dst
end tell
EOF
[ -n "$new_title" ] || exit 0
# `get body` omits attachment markup, so the round-trip below would silently drop every
# attachment. Refuse rather than destroy: rename those notes from the app instead.
n_att=$(osascript -e "tell application \"Notes\" to tell account \"$account\" to get count of attachments of note \"$title\" of $dst")
if [ "$n_att" -gt 0 ]; then
echo "refusing to retitle \"$2\": it has $n_att attachment(s) that a body rewrite would destroy — rename it in the Notes app" >&2
exit 1
fi
# The title IS the body's first line — rewrite it and Notes re-derives the name. Never `set name`
# as well: it blanks that line. Notes has by then turned the <h1> into a styled span, so the whole
# first block is replaced rather than the tag. A body that opens on a list or a table has no title
# block at all, so fall back to prepending one instead of silently leaving the old name in place.
export NOTES_NEW_TITLE=$new_title # perl reads it: a prefix assignment would only reach osascript
osascript -e "tell application \"Notes\" to tell account \"$account\" to get body of note \"$title\" of $dst" \
| perl -0777 -e '$_ = <>; my $t = "<div><b><span style=\"font-size: 24px\">$ENV{NOTES_NEW_TITLE}</span></b><br></div>";
s{\A\s*<div>.*?</div>}{$t}se or s{\A}{$t}; print' \
| tr -d '\n' > "${TMPDIR:-/tmp}/notes-body.$$"
raw=$(cat "${TMPDIR:-/tmp}/notes-body.$$")
rm -f "${TMPDIR:-/tmp}/notes-body.$$"
# A broken pipeline leaves this empty, and `set body of n to ""` wipes the note without any
# AppleScript error. Bail out while the original body is still intact.
if [ -z "$raw" ]; then
echo "refusing to retitle \"$2\": rewritten body came back empty — the note is unchanged" >&2
exit 1
fi
body=$(as_quote "$raw")
osascript <<EOF
tell application "Notes" to tell account "$account"
set n to note "$title" of $dst
set body of n to "$body"
return (get name of n)
end tell
EOF
;;
*) usage ;;
Expand Down
3 changes: 2 additions & 1 deletion scripts/upgrade
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ fi

echo "ℹ️ npm global packages upgrade"
if command -v npm &> /dev/null; then
npm update -g
# 3-day quarantine: ignore versions published too recently to be vetted
npm update -g --before="$(date -u -v-3d +%Y-%m-%dT%H:%M:%SZ)"
else
echo " Warning: npm not found, skipping npm global package updates"
fi
Expand Down
Loading