From 1bc9b2df1a5002e4449faf16f8c57d01b85dc40c Mon Sep 17 00:00:00 2001 From: Li Shixiong Date: Mon, 13 Jul 2026 10:39:06 +0800 Subject: [PATCH] Replace memo-based apple-notes skill with native osascript workflow Replace the third-party `memo` CLI dependency with a native macOS `osascript` path driven through a helper script, so the skill works without `brew tap` / `brew install memo`. - SKILL.md: rewritten to the modern section order; description <=60 chars, one sentence ending with a period (AGENTS.md #1); prose names the Hermes `terminal` tool as the interaction surface, not osascript (AGENTS.md #2); `author` credits the human contributor first (AGENTS.md #4) - scripts/apple_notes.py: parameterized, noninteractive helper exposing list-folders, list-notes, search, read, create, append, create-folder, move. AppleScript string building is separated from osascript execution so it is unit-testable (AGENTS.md #6) - tests/skills/test_apple_notes_skill.py: metadata compliance, AppleScript string building/escaping, and CLI dispatch (stdlib + pytest + unittest.mock) - all mutations validated against a live Notes.app scratch folder --- skills/apple/apple-notes/SKILL.md | 131 +++++---- .../apple/apple-notes/scripts/apple_notes.py | 254 ++++++++++++++++++ tests/skills/test_apple_notes_skill.py | 238 ++++++++++++++++ 3 files changed, 564 insertions(+), 59 deletions(-) create mode 100644 skills/apple/apple-notes/scripts/apple_notes.py create mode 100644 tests/skills/test_apple_notes_skill.py diff --git a/skills/apple/apple-notes/SKILL.md b/skills/apple/apple-notes/SKILL.md index 020f0d641df40..62cd93fbd90af 100644 --- a/skills/apple/apple-notes/SKILL.md +++ b/skills/apple/apple-notes/SKILL.md @@ -1,90 +1,103 @@ --- name: apple-notes -description: "Manage Apple Notes via memo CLI: create, search, edit." -version: 1.0.0 -author: Hermes Agent +description: Read, search, create, and organize Apple Notes on macOS. +version: 1.1.0 +author: Li shixiong (lishix520); Hermes Agent license: MIT platforms: [macos] metadata: hermes: tags: [Notes, Apple, macOS, note-taking] related_skills: [obsidian] -prerequisites: - commands: [memo] --- -# Apple Notes +# Apple Notes Skill -Use `memo` to manage Apple Notes directly from the terminal. Notes sync across all Apple devices via iCloud. - -## Prerequisites - -- **macOS** with Notes.app -- Install: `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo` -- Grant Automation access to Notes.app when prompted (System Settings → Privacy → Automation) +Use the `terminal` tool to drive Apple Notes natively through the helper script `scripts/apple_notes.py`, which runs `osascript` against Notes.app. This replaces third-party CLIs such as `memo`: no `brew tap` or install step, and every operation is noninteractive and parameterized. macOS only; notes sync to iPhone and iPad through iCloud. ## When to Use -- User asks to create, view, or search Apple Notes -- Saving information to Notes.app for cross-device access -- Organizing notes into folders -- Exporting notes to Markdown/HTML +- Read, search, create, or append to Apple Notes +- Save information to Notes.app for cross-device access +- Organize notes into folders or move notes between folders ## When NOT to Use -- Obsidian vault management → use the `obsidian` skill -- Bear Notes → separate app (not supported here) -- Quick agent-only notes → use the `memory` tool instead - -## Quick Reference - -### View Notes +- Obsidian vault work -> use the `obsidian` skill +- Bear Notes -> separate app, not supported +- Agent-internal notes that do not need to sync -> use the `memory` tool -```bash -memo notes # List all notes -memo notes -f "Folder Name" # Filter by folder -memo notes -s "query" # Search notes (fuzzy) -``` - -### Create Notes - -```bash -memo notes -a # Interactive editor -memo notes -a "Note Title" # Quick add with title -``` +## Prerequisites -### Edit Notes +- macOS with Notes.app installed. +- `osascript` ships with macOS; there is no install step. +- Grant Automation access to Notes.app the first time `osascript` drives it (System Settings -> Privacy & Security -> Automation). The prompt appears once per binary. -```bash -memo notes -e # Interactive selection to edit -``` +## How to Run -### Delete Notes +Invoke the helper script with the `terminal` tool. Every subcommand takes explicit arguments and never prompts: ```bash -memo notes -d # Interactive selection to delete -``` +SCRIPT="skills/apple/apple-notes/scripts/apple_notes.py" -### Move Notes +# Inspect the vault +python3 "$SCRIPT" list-folders +python3 "$SCRIPT" list-notes --folder "Notes" +python3 "$SCRIPT" search --query "standup" -```bash -memo notes -m # Move note to folder (interactive) -``` +# Read +python3 "$SCRIPT" read --title "Standup Notes" --folder "Notes" -### Export Notes +# Create and append (plain-text body is converted to Notes HTML) +python3 "$SCRIPT" create --title "Standup Notes" --body "First entry" --folder "Notes" +python3 "$SCRIPT" append --title "Standup Notes" --body "Second entry" --folder "Notes" -```bash -memo notes -ex # Export to HTML/Markdown +# Folders and moves +python3 "$SCRIPT" create-folder --name "Project Alpha" +python3 "$SCRIPT" move --title "Standup Notes" --src "Notes" --dest "Project Alpha" ``` -## Limitations +Pass `--body-html` instead of `--body` to write raw Notes HTML (for example a structured project-update template) without conversion. -- Cannot edit notes containing images or attachments -- Interactive prompts require terminal access (use pty=true if needed) -- macOS only — requires Apple Notes.app - -## Rules +## Quick Reference -1. Prefer Apple Notes when user wants cross-device sync (iPhone/iPad/Mac) -2. Use the `memory` tool for agent-internal notes that don't need to sync -3. Use the `obsidian` skill for Markdown-native knowledge management +| Action | Command | +| --- | --- | +| List folders | `list-folders` | +| List notes in a folder | `list-notes --folder F` | +| Search note titles | `search --query Q` | +| Read a note | `read --title T [--folder F]` | +| Create a note | `create --title T --body B [--folder F]` | +| Append to a note | `append --title T --body B [--folder F]` | +| Create a folder | `create-folder --name N` | +| Move a note | `move --title T --dest D [--src S]` | + +When `--folder` is omitted, `create` writes to the default folder and `read`/`append`/`move` search across all folders. + +## Procedure + +1. Run `list-folders` first to resolve the target folder. If the right folder does not exist, create it with `create-folder`. +2. Before creating a note, run `search` by title to avoid duplicates. +3. To update an existing note, prefer `append` over recreating it; only rewrite when the user explicitly asks. +4. If `search` returns multiple matches, narrow by folder or keyword before acting. Do not guess. +5. For moves, confirm source and destination with the user before executing bulk moves. + +## Pitfalls + +- Notes returns the body as HTML-like content (`
`, `
`); `read` returns it verbatim. +- `search` matches note titles only. To find text inside a note, `read` it and search locally. +- Folder names resolve across accounts; if two accounts share a folder name, the first match is used. +- Automation permission is per-binary. A different Python interpreter may re-trigger the permission prompt. +- `append` concatenates HTML; very large notes may render slowly in Notes.app. + +## Verification + +Confirm the skill end to end with the `terminal` tool against a scratch folder, then delete it: + +1. `list-folders` returns the current folders. +2. `create-folder --name "Hermes Skill Test"` creates a scratch folder. +3. `create --title "Skill Check" --body "hello" --folder "Hermes Skill Test"` creates a note. +4. `append --title "Skill Check" --body "more" --folder "Hermes Skill Test"` appends. +5. `read --title "Skill Check" --folder "Hermes Skill Test"` returns both entries. +6. `move --title "Skill Check" --src "Hermes Skill Test" --dest "Notes"` moves it. +7. Delete the scratch folder from Notes.app when finished. diff --git a/skills/apple/apple-notes/scripts/apple_notes.py b/skills/apple/apple-notes/scripts/apple_notes.py new file mode 100644 index 0000000000000..e711b73e00e7a --- /dev/null +++ b/skills/apple/apple-notes/scripts/apple_notes.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Native Apple Notes helper for the ``apple-notes`` skill. + +All operations are parameterized and noninteractive: every command takes +explicit arguments and never prompts the user. The AppleScript that drives +Notes.app is assembled by pure functions (``build_*``) and executed by +``run_applescript``, so the string-building logic is unit-testable without +macOS or a live Notes database. + +Invoke through the Hermes ``terminal`` tool, for example:: + + terminal: python3 skills/apple/apple-notes/scripts/apple_notes.py list-folders +""" + +from __future__ import annotations + +import argparse +import html +import subprocess +import sys +from typing import Optional + +OSASCRIPT_TIMEOUT = 30 + + +def escape_applescript_string(value: str) -> str: + """Escape a string for an AppleScript double-quoted literal. + + AppleScript requires backslash and double-quote to be escaped with a + leading backslash. Other characters pass through unchanged because body + text is first converted by ``text_to_notes_html``. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def text_to_notes_html(text: str) -> str: + """Convert plain text into Notes body HTML. + + HTML-special characters are escaped so user content cannot inject markup, + then newlines become ``
`` line breaks. + """ + escaped = html.escape(text, quote=False) + return escaped.replace("\n", "
") + + +def _q(value: str) -> str: + """Wrap an escaped value in AppleScript double quotes.""" + return '"' + escape_applescript_string(value) + '"' + + +def build_list_folders_script() -> str: + return 'tell application "Notes" to get name of every folder' + + +def build_list_notes_script(folder: str) -> str: + # Scope with a tell block: a one-liner like `every note of first folder + # whose name is F` parses the `whose` against the note, not the folder, + # and silently returns nothing. + f = _q(folder) + return ( + 'tell application "Notes"\n' + " tell first folder whose name is " + f + "\n" + " get name of every note\n" + " end tell\n" + "end tell" + ) + + +def build_search_script(query: str) -> str: + # Title-only search: Notes `whose` on the rich-text body is unreliable. + return ( + 'tell application "Notes" to get name of every note ' + "whose name contains " + _q(query) + ) + + +def build_read_note_script(title: str, folder: Optional[str] = None) -> str: + t = _q(title) + if folder: + f = _q(folder) + return ( + 'tell application "Notes"\n' + " tell first folder whose name is " + f + "\n" + " get body of first note whose name is " + t + "\n" + " end tell\n" + "end tell" + ) + return 'tell application "Notes" to get body of first note whose name is ' + t + + +def build_create_note_script(title: str, body_html: str, folder: Optional[str] = None) -> str: + t = _q(title) + b = _q(body_html) + props = "{name:" + t + ", body:" + b + "}" + if folder: + f = _q(folder) + return ( + 'tell application "Notes"\n' + " tell first folder whose name is " + f + "\n" + " make new note with properties " + props + "\n" + " end tell\n" + "end tell" + ) + return ( + 'tell application "Notes"\n' + " tell first folder\n" + " make new note with properties " + props + "\n" + " end tell\n" + "end tell" + ) + + +def build_append_note_script(title: str, body_html: str, folder: Optional[str] = None) -> str: + t = _q(title) + b = _q(body_html) + if folder: + f = _q(folder) + return ( + 'tell application "Notes"\n' + " tell first folder whose name is " + f + "\n" + " set n to first note whose name is " + t + "\n" + " set body of n to (body of n) & " + b + "\n" + " end tell\n" + "end tell" + ) + return ( + 'tell application "Notes"\n' + " set n to first note whose name is " + t + "\n" + " set body of n to (body of n) & " + b + "\n" + "end tell" + ) + + +def build_create_folder_script(name: str) -> str: + return 'tell application "Notes" to make new folder with properties {name:' + _q(name) + "}" + + +def build_move_note_script(title: str, dest: str, src: Optional[str] = None) -> str: + t = _q(title) + d = _q(dest) + if src: + s = _q(src) + return ( + 'tell application "Notes"\n' + " set destFolder to first folder whose name is " + d + "\n" + " tell first folder whose name is " + s + "\n" + " move first note whose name is " + t + " to destFolder\n" + " end tell\n" + "end tell" + ) + return ( + 'tell application "Notes"\n' + " set destFolder to first folder whose name is " + d + "\n" + " move first note whose name is " + t + " to destFolder\n" + "end tell" + ) + + +def run_applescript(script: str) -> str: + """Run an AppleScript via ``osascript -`` (stdin) and return stdout.""" + result = subprocess.run( + ["osascript", "-"], + input=script, + capture_output=True, + text=True, + timeout=OSASCRIPT_TIMEOUT, + ) + if result.returncode != 0: + raise RuntimeError("osascript failed: " + result.stderr.strip()) + return result.stdout + + +def _emit(text: str) -> int: + if text: + sys.stdout.write(text) + if not text.endswith("\n"): + sys.stdout.write("\n") + return 0 + + +def _body_arg(args: argparse.Namespace) -> str: + if args.body_html is not None: + return args.body_html + return text_to_notes_html(args.body) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="apple_notes.py", + description="Native, noninteractive Apple Notes helper.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("list-folders", help="List every Notes folder.") + + p = sub.add_parser("list-notes", help="List note titles in a folder.") + p.add_argument("--folder", required=True) + + p = sub.add_parser("search", help="Search note titles by substring.") + p.add_argument("--query", required=True) + + p = sub.add_parser("read", help="Read a note body.") + p.add_argument("--title", required=True) + p.add_argument("--folder") + + p = sub.add_parser("create", help="Create a note.") + p.add_argument("--title", required=True) + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--body", help="Plain-text body (converted to Notes HTML).") + g.add_argument("--body-html", help="Raw HTML body (no conversion).") + p.add_argument("--folder") + + p = sub.add_parser("append", help="Append HTML to an existing note body.") + p.add_argument("--title", required=True) + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--body", help="Plain-text body (converted to Notes HTML).") + g.add_argument("--body-html", help="Raw HTML body (no conversion).") + p.add_argument("--folder") + + p = sub.add_parser("create-folder", help="Create a new folder.") + p.add_argument("--name", required=True) + + p = sub.add_parser("move", help="Move a note to another folder.") + p.add_argument("--title", required=True) + p.add_argument("--dest", required=True) + p.add_argument("--src", help="Source folder (defaults to searching all folders).") + return parser + + +def main(argv=None) -> int: + args = _build_parser().parse_args(argv) + cmd = args.command + + if cmd == "list-folders": + return _emit(run_applescript(build_list_folders_script())) + if cmd == "list-notes": + return _emit(run_applescript(build_list_notes_script(args.folder))) + if cmd == "search": + return _emit(run_applescript(build_search_script(args.query))) + if cmd == "read": + return _emit(run_applescript(build_read_note_script(args.title, args.folder))) + if cmd == "create": + return _emit(run_applescript(build_create_note_script(args.title, _body_arg(args), args.folder))) + if cmd == "append": + return _emit(run_applescript(build_append_note_script(args.title, _body_arg(args), args.folder))) + if cmd == "create-folder": + return _emit(run_applescript(build_create_folder_script(args.name))) + if cmd == "move": + return _emit(run_applescript(build_move_note_script(args.title, args.dest, args.src))) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/skills/test_apple_notes_skill.py b/tests/skills/test_apple_notes_skill.py new file mode 100644 index 0000000000000..ef096c6b9d5e8 --- /dev/null +++ b/tests/skills/test_apple_notes_skill.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SKILL_DIR = REPO_ROOT / "skills" / "apple" / "apple-notes" +SCRIPT_PATH = SKILL_DIR / "scripts" / "apple_notes.py" +SKILL_MD = SKILL_DIR / "SKILL.md" + + +def load_module(): + spec = importlib.util.spec_from_file_location("apple_notes_skill", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _frontmatter() -> str: + text = SKILL_MD.read_text(encoding="utf-8") + m = re.search(r"^---\s*\n(.*?)\n---\s*$", text, re.MULTILINE | re.DOTALL) + assert m, "SKILL.md frontmatter not found" + return m.group(1) + + +# --- skill metadata (HARDLINE authoring standards) --- + + +def test_description_is_short_one_sentence_ending_with_period(): + desc = None + for line in _frontmatter().splitlines(): + m = re.match(r"^description:\s*(.*)$", line) + if m: + desc = m.group(1).strip() + break + assert desc, "description field missing from frontmatter" + assert len(desc) <= 60, f"description is {len(desc)} chars, must be <= 60: {desc!r}" + assert desc.endswith("."), f"description must end with a period: {desc!r}" + assert desc.count(".") == 1, f"description must be one sentence: {desc!r}" + + +def test_platforms_gated_to_macos_only(): + m = re.search(r"^platforms:\s*(.*)$", _frontmatter(), re.MULTILINE) + assert m, "platforms field missing" + platforms = m.group(1).lower() + assert "macos" in platforms, "osascript skill must declare macos" + assert "linux" not in platforms and "windows" not in platforms, ( + "osascript is macOS-only; no other platforms may be claimed" + ) + + +def test_author_credits_human_contributor_first(): + m = re.search(r"^author:\s*(.*)$", _frontmatter(), re.MULTILINE) + assert m, "author field missing" + author = m.group(1).strip() + assert "lishix520" in author, f"contributor handle missing from author: {author!r}" + handle_idx = author.find("lishix520") + hermes_idx = author.lower().find("hermes agent") + assert hermes_idx == -1 or handle_idx < hermes_idx, ( + f"human contributor must be credited before 'Hermes Agent': {author!r}" + ) + + +def test_skill_md_names_terminal_tool_and_helper_script(): + text = SKILL_MD.read_text(encoding="utf-8") + assert "`terminal`" in text, "prose must name the Hermes `terminal` tool" + assert "scripts/apple_notes.py" in text, "SKILL.md must reference the helper script by path" + assert "scripts/apple_notes.py" in text + + +# --- AppleScript string building (pure, no macOS needed) --- + + +def test_escape_applescript_string(): + mod = load_module() + assert mod.escape_applescript_string("plain") == "plain" + assert mod.escape_applescript_string('he said "hi"') == 'he said \\"hi\\"' + assert mod.escape_applescript_string("back\\slash") == "back\\\\slash" + + +def test_text_to_notes_html_escapes_markup_and_breaks_lines(): + mod = load_module() + out = mod.text_to_notes_html("hi\nline2") + assert out == "<b>hi</b>
line2" + # user content must not survive as live markup + assert "" not in out + malicious = "" + assert "