From 90705436856b40fedc6bfb608e30cee56ac603a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Elet?= Date: Wed, 29 Jul 2026 11:58:17 +0200 Subject: [PATCH 1/3] feat(upgrade): quarantine npm global updates for 3 days Recently published versions are the window where supply chain compromises are still undetected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DUKBFkNf9KaDiKiWqfW2Vq --- scripts/upgrade | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 4276bbe965f706e5afa632f88d11e89c30422d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Elet?= Date: Wed, 29 Jul 2026 15:24:45 +0200 Subject: [PATCH 2/3] feat(skills): file and retitle notes with notes.sh move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaging the Apple Notes inbox was the one workflow the skill left to hand-written AppleScript every time. `notes.sh move` now takes nested slash-separated paths, creates the missing destination levels, refuses to move out of a shared folder, and optionally retitles the note as it files it. Retitling rewrites the body's first line, since Notes derives the name from it. That round-trip goes through `get body`, which omits attachment markup — writing the HTML back deletes every attachment for good, and the note lands in Recently Deleted already emptied of them. Guard against it rather than destroy: refuse to retitle a note that has any. Also record the two traps found the hard way: a prefix env assignment in front of a pipeline reaches only the first command, so perl read an empty header and silently inserted nothing; and a title's segment count has to mirror the destination's depth, or freshly filed notes come out inconsistent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016sULBMwNmi5seHJ8FF5a67 --- .agents/skills/README.md | 2 +- .agents/skills/apple-notes/SKILL.md | 64 +++++++++++++++++-- .agents/skills/apple-notes/scripts/notes.sh | 70 ++++++++++++++++++++- 3 files changed, 128 insertions(+), 8 deletions(-) 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..4d58995 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,21 @@ 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 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..6a1cb3b 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,47 @@ 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.$$" + body=$(as_quote "$(cat "${TMPDIR:-/tmp}/notes-body.$$")") + rm -f "${TMPDIR:-/tmp}/notes-body.$$" + 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 ;; From 7cd0afc3a708208b085871e44198d3590a7abd0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Elet?= <sebastien@elet.fr> Date: Thu, 30 Jul 2026 09:37:38 +0200 Subject: [PATCH 3/3] fix(skills): stop a failed retitle from wiping the note body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty rewritten body was written back verbatim, and `set body of n to ""` succeeds silently: the note kept its place but lost its content and its title. Bail out while the original body is still intact, and record the two other traps found while triaging the inbox — a header must sit on line 2 because retitling replaces the first div, and a link attachment still exposes its URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sULBMwNmi5seHJ8FF5a67 --- .agents/skills/apple-notes/SKILL.md | 12 ++++++++++++ .agents/skills/apple-notes/scripts/notes.sh | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.agents/skills/apple-notes/SKILL.md b/.agents/skills/apple-notes/SKILL.md index 4d58995..be7d594 100644 --- a/.agents/skills/apple-notes/SKILL.md +++ b/.agents/skills/apple-notes/SKILL.md @@ -141,6 +141,18 @@ end tell 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. diff --git a/.agents/skills/apple-notes/scripts/notes.sh b/.agents/skills/apple-notes/scripts/notes.sh index 6a1cb3b..9cb7133 100755 --- a/.agents/skills/apple-notes/scripts/notes.sh +++ b/.agents/skills/apple-notes/scripts/notes.sh @@ -104,8 +104,15 @@ EOF | 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.$$" - body=$(as_quote "$(cat "${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