diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 8528558..5adddba 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -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,… | diff --git a/.agents/skills/apple-notes/SKILL.md b/.agents/skills/apple-notes/SKILL.md index f95808e..be7d594 100644 --- a/.agents/skills/apple-notes/SKILL.md +++ b/.agents/skills/apple-notes/SKILL.md @@ -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 @@ -35,13 +36,17 @@ anything into them. ```bash .agents/skills/apple-notes/scripts/notes.sh folder [account] .agents/skills/apple-notes/scripts/notes.sh note [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 @@ -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 @@ -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 &`. diff --git a/.agents/skills/apple-notes/scripts/notes.sh b/.agents/skills/apple-notes/scripts/notes.sh index 9ee8938..9cb7133 100755 --- a/.agents/skills/apple-notes/scripts/notes.sh +++ b/.agents/skills/apple-notes/scripts/notes.sh @@ -1,8 +1,9 @@ #!/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() { @@ -10,6 +11,9 @@ usage() { 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 } @@ -17,6 +21,27 @@ EOF # 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 @@ -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 ;; diff --git a/scripts/upgrade b/scripts/upgrade index 777b599..21f869c 100755 --- a/scripts/upgrade +++ b/scripts/upgrade @@ -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