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 "