diff --git a/AGENTS.md b/AGENTS.md index d84140f..3681e0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,17 @@ Rust workspace with 4 crates: - **flicknote-auth** — Supabase GoTrue authentication (OTP + OAuth2/PKCE) - **flicknote-sync** — Daemon application host, typed RPC boundary, backend ownership, and PowerSync ↔ Supabase sync -### modify vs replace +### MCP interface + +FlickNote MCP is the formal model interface for note operations. The CLI remains +for human and operational workflows; content and section mutations are not CLI +commands. + +Every MCP structured result must have an object root, and each advertised output +schema must be precise and derived from its boundary DTO. Arbitrary JSON schema +terms must use object form rather than bare boolean terms. Every MCP change must +pass the repository-wide strict-client output-schema contract test. -- `flicknote modify ` — edit-mode: exact-string replace via `===BEFORE===`/`===AFTER===` blocks, plus metadata -- `flicknote replace --section ` — replaces one complete section subtree, including its heading; it does not change note metadata ## Build & Test @@ -80,9 +87,9 @@ Commit scope: `ci` The `skills/` directory contains command reference docs for AI agents: -- `skills/flicknote.md` — FlickNote CLI command reference +- `skills/flicknote.md` — concise MCP-first FlickNote guidance -Agent quick reference is deployed via `ttal sync` to the runtime agent rules. +The bundled skill is installed with `flicknote skill install`. ## Commit Style diff --git a/README.md b/README.md index cfb2915..1090c01 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Daemon-backed note management CLI with local-first sync. The CLI and MCP server - **Add & capture notes** — text, URLs (auto-detected as links), files - **List & search notes** — filter by type, project, or keyword (`find`) - **Get note details** — retrieve by numeric short ID; view heading structure with `--tree` -- **Edit notes** — modify exact text, or replace, append, insert, remove, and rename sections by ID +- **Edit notes** — human editor, append, content, and metadata workflows; structured content and section mutations are provided by MCP - **MCP server** — typed local note, source, and project tools over stdio - **Archive notes** — archive and unarchive - **Authentication** — email OTP or OAuth (Google/Apple) via Supabase @@ -93,20 +93,13 @@ flicknote unshare flicknote project share flicknote project unshare -# Edit note content -# Precision edit (exact-string replace) -cat <<'EDIT' | flicknote modify -===BEFORE=== -typo here -===AFTER=== -fixed here -EDIT +# Edit note metadata +flicknote modify --project myproject +flicknote modify --project myproject --flagged +flicknote modify --unflagged -# Replace one section, including its heading and child sections -echo "## Heading -body" | flicknote replace --section - -# For a whole-note rewrite, archive the old note and create a new note. +# Content and section mutations use the structured MCP interface. The MCP +# schemas carry exact before/after fields and section-scoped operations. # Append echo "more content" | flicknote append @@ -140,14 +133,16 @@ start it as a subprocess: } ``` -The MCP server requires the local daemon. It exposes typed note, note-source, -and project tools. Note content -and exact `before`/`after` edits are JSON fields, so callers do not need shell -heredocs. Note tools accept numeric short IDs and do not expose internal UUIDs; -project tools use project names. `note_source` reads stored source data, while -`note_get` reads editable note content. Every data tool uses the running daemon; -the MCP process never opens SQLite. The server does not start the daemon -automatically. +The MCP server requires the local daemon. It exposes typed note, discovery, +note-source, and project tools. Note content and exact `before`/`after` edits +are structured JSON fields, so callers do not need shell heredocs. Note tools +accept numeric short IDs and do not expose internal UUIDs; project tools use +project names. `note_source` reads stored source data, while `note_get` reads +editable note content. Every data tool uses the running daemon; the MCP process +never opens SQLite. The server does not start the daemon automatically. + +The Gateway CLI command remains available for internal development and +maintenance requests; it is not the formal agent interface. ## Configuration diff --git a/RULE.md b/RULE.md deleted file mode 100644 index d5dfc3b..0000000 --- a/RULE.md +++ /dev/null @@ -1,135 +0,0 @@ -# flicknote Quick Reference - -## Add - -```bash -flicknote add "content" --project -flicknote add "https://example.com" # auto-detected as link -``` - -For multiline content with special characters, use heredoc: - -```bash -cat <<'EOF' | flicknote add --project -# My Note -Some content with **markdown** and $variables -EOF -``` - -## List & Find - -```bash -flicknote list --project -flicknote find "keyword" -flicknote find "keyword1" "keyword2" # OR match -flicknote list --json -flicknote count # count active notes -flicknote count --project # count in project -``` - -## Read - -```bash -flicknote detail -flicknote detail --tree # heading structure with section IDs -flicknote detail --json -flicknote detail --archived # read an archived note -flicknote content # content-only as pure markdown -flicknote content --section -``` - -To target a section, first run `--tree` to see IDs, then use the ID: - -```bash -flicknote detail abc12345 --tree -# └─ # My Note -# ├─ [3K] ## Summary -# └─ [aZ] ## Details -flicknote content abc12345 --section 3K -``` - -## Replace a section - -> `flicknote replace --section ` overwrites one complete section subtree, including its heading. Prefer `modify` for precision edits and metadata. - -```bash -echo "## New Heading -new body" | flicknote replace --section # replace whole section incl. heading -``` - -`--section` is required and stdin must start with an ATX or setext heading. The -heading level is capped at the original section's level so outlines don't -skew. Use `modify` for project/flagged changes. To replace a whole note, -archive the old note and create a new one. - -## Modify (edit-mode + metadata) - -> `flicknote modify ` does precision string-replace via `===BEFORE===`/`===AFTER===` blocks, plus metadata. - -```bash -# Edit mode: exact-string replacement (fails on zero or multiple matches) -cat <<'EDIT' | flicknote modify -===BEFORE=== -old text (exactly as in the note, whitespace-sensitive) -===AFTER=== -new text -EDIT - -# Scope to a section -cat <<'EDIT' | flicknote modify --section -===BEFORE=== -old text inside that section -===AFTER=== -new text -EDIT - -# Metadata only -flicknote modify --project -flicknote modify --flagged # or --unflagged -``` - -Rules: -- **Exact match, whitespace-sensitive.** No fuzzy fallbacks. -- **Unique-match required.** If `BEFORE` matches 0 or >1 times, you get a clear error. Add surrounding context to disambiguate. -- **Single block per call.** Multiple `===BEFORE===`/`===AFTER===` pairs in one stdin → error. Run modify multiple times for multiple edits. -- **Append** is a different command: `echo "more" | flicknote append `. - -Mutating commands (`modify`, `delete`, `rename`, `insert`) print the updated `--tree` after making changes. - -### Migration from legacy `modify` - -| Old | New | -|---------------------------------------------------------------|-----------------------------------------------------| -| Whole-note replacement | Archive the old note, then create a new note | -| `echo body | flicknote modify --section ` | `echo "## Heading -body" | flicknote replace --section ` | -| `cat "## X -..." | flicknote modify --section --with-heading` | `echo "## X -..." | flicknote replace --section ` (heading always in stdin; --with-heading removed) | - -`--with-heading` is removed. For `replace --section`, the heading is always required in stdin. - -## Section Operations - -```bash -flicknote delete --section -flicknote rename --section "New Name" -echo "content" | flicknote insert --before -echo "content" | flicknote insert --after -# IDs are 2-character base62 (0–9, A–Z, a–z) — run --tree to find them; H1 headings have no ID -``` - -## Open in Browser - -```bash -flicknote open # open note in browser -``` - -## Delete & Restore - -```bash -flicknote delete -flicknote restore -``` - -Never pipe flicknote content through sed/awk — use modify/insert instead. diff --git a/flicknote-cli/src/commands/delete.rs b/flicknote-cli/src/commands/delete.rs index 71f85bd..1afedec 100644 --- a/flicknote-cli/src/commands/delete.rs +++ b/flicknote-cli/src/commands/delete.rs @@ -1,45 +1,24 @@ use clap::Args; use flicknote_core::error::CliError; -use flicknote_core::services::dto::{NoteArchiveResult, NoteMutationResult}; +use flicknote_core::services::dto::NoteArchiveResult; use flicknote_sync::ipc::{AppRequest, DaemonClient}; -use super::util::{display_summary_id, print_section_tree}; - #[derive(Args)] pub(crate) struct DeleteArgs { /// Note ID. Use the numeric short ID shown in list/detail. Full UUIDs are also accepted for compatibility. id: String, - /// Remove a specific section by section ID (2-char base62) instead of deleting the note - #[arg(short = 's', long = "section")] - section: Option, } pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &DeleteArgs) -> Result<(), CliError> { - if let Some(ref section_id) = args.section { - let result: NoteMutationResult = daemon - .call(AppRequest::NoteDeleteSection { - id: args.id.clone(), - section: section_id.clone(), - }) - .await?; - println!( - "Removed section {} from note {}.\n", - section_id, - display_summary_id(&result.note) - ); - print_section_tree(&result.sections); - } else { - let result: NoteArchiveResult = daemon - .call(AppRequest::NoteArchive { - id: args.id.clone(), - }) - .await?; - let display_id = result - .short_id - .map(|id| id.to_string()) - .unwrap_or(result.uuid); - println!("Deleted note {}.", display_id); - } - + let result: NoteArchiveResult = daemon + .call(AppRequest::NoteArchive { + id: args.id.clone(), + }) + .await?; + let display_id = result + .short_id + .map(|id| id.to_string()) + .unwrap_or(result.uuid); + println!("Deleted note {}.", display_id); Ok(()) } diff --git a/flicknote-cli/src/commands/detail.rs b/flicknote-cli/src/commands/detail.rs index 942d4dc..92ac77e 100644 --- a/flicknote-cli/src/commands/detail.rs +++ b/flicknote-cli/src/commands/detail.rs @@ -1,7 +1,6 @@ use clap::Args; use flicknote_core::error::CliError; -use flicknote_core::services::dto::NoteDetail; -use flicknote_core::types::Note; +use flicknote_core::services::dto::{NoteDetail, NoteRecord}; use flicknote_sync::ipc::{AppRequest, DaemonClient}; use super::util::{display_summary_id, note_json, print_section_tree}; @@ -40,7 +39,7 @@ pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &DetailArgs) -> Result< return Ok(()); } if args.json { - let note: Note = daemon + let note: NoteRecord = daemon .call(AppRequest::NoteRecord { id: detail.note.uuid.clone(), archived: args.archived, diff --git a/flicknote-cli/src/commands/insert.rs b/flicknote-cli/src/commands/insert.rs deleted file mode 100644 index 52361c3..0000000 --- a/flicknote-cli/src/commands/insert.rs +++ /dev/null @@ -1,56 +0,0 @@ -use clap::Args; -use flicknote_core::error::CliError; -use flicknote_core::services::dto::{InsertPosition, NoteMutationResult}; -use flicknote_sync::ipc::{AppRequest, DaemonClient}; - -use super::util::{display_summary_id, print_section_tree, read_stdin_required}; - -const INSERT_HELP: &str = include_str!("../help/insert.md"); - -#[derive(Args)] -#[command( - group(clap::ArgGroup::new("position").required(true)), - after_help = INSERT_HELP -)] -pub(crate) struct InsertArgs { - /// Note ID. Use the numeric short ID shown in list/detail. Full UUIDs are also accepted for compatibility. - id: String, - /// Insert before this section - #[arg(long, group = "position")] - before: Option, - /// Insert after this section - #[arg(long, group = "position")] - after: Option, -} - -pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &InsertArgs) -> Result<(), CliError> { - let (section, position) = match (&args.before, &args.after) { - (Some(section), None) => (section.as_str(), InsertPosition::Before), - (None, Some(section)) => (section.as_str(), InsertPosition::After), - _ => { - return Err(CliError::Other( - "Exactly one of --before or --after is required.".into(), - )); - } - }; - - let insert_content = read_stdin_required()?; - let result: NoteMutationResult = daemon - .call(AppRequest::NoteInsert { - id: args.id.clone(), - section: section.to_string(), - position, - content: insert_content, - }) - .await?; - let position = match position { - InsertPosition::Before => "before", - InsertPosition::After => "after", - }; - println!( - "Inserted content {position} section {section} in note {}.\n", - display_summary_id(&result.note) - ); - print_section_tree(&result.sections); - Ok(()) -} diff --git a/flicknote-cli/src/commands/mod.rs b/flicknote-cli/src/commands/mod.rs index d26c005..b2711c1 100644 --- a/flicknote-cli/src/commands/mod.rs +++ b/flicknote-cli/src/commands/mod.rs @@ -10,15 +10,12 @@ pub(crate) mod entity; pub(crate) mod find; pub(crate) mod gateway; pub(crate) mod import; -pub(crate) mod insert; pub(crate) mod list; pub(crate) mod login; pub(crate) mod logout; pub(crate) mod modify; pub(crate) mod open; pub(crate) mod project; -pub(crate) mod rename; -pub(crate) mod replace; pub(crate) mod restore; pub(crate) mod share; pub(crate) mod skill; diff --git a/flicknote-cli/src/commands/modify.rs b/flicknote-cli/src/commands/modify.rs index 07fa9fd..d183379 100644 --- a/flicknote-cli/src/commands/modify.rs +++ b/flicknote-cli/src/commands/modify.rs @@ -1,50 +1,32 @@ use clap::Args; use flicknote_core::error::CliError; use flicknote_core::services::dto::{NoteModifyInput, NoteMutationResult}; -use flicknote_core::services::edit_match::{is_edit_mode, parse_edit_input}; use flicknote_sync::ipc::{AppRequest, DaemonClient}; -use super::util::{display_summary_id, print_section_tree, try_read_stdin}; +use super::util::{display_summary_id, print_section_tree}; const MODIFY_HELP: &str = include_str!("../help/modify.md"); #[derive(Args)] -#[command(after_help = MODIFY_HELP)] +#[command( + group(clap::ArgGroup::new("metadata").required(true).multiple(true)), + after_help = MODIFY_HELP +)] pub(crate) struct ModifyArgs { /// Note ID. Use the numeric short ID shown in list/detail. Full UUIDs are also accepted for compatibility. id: String, - /// Edit only the named section (scope = full section including heading) - #[arg(short = 's', long = "section")] - section: Option, /// Move note to this project - #[arg(short = 'p', long = "project")] + #[arg(short = 'p', long = "project", group = "metadata")] project: Option, /// Mark note as flagged - #[arg(long, conflicts_with = "unflagged")] + #[arg(long, group = "metadata", conflicts_with = "unflagged")] flagged: bool, /// Remove flagged status - #[arg(long, conflicts_with = "flagged")] + #[arg(long, group = "metadata", conflicts_with = "flagged")] unflagged: bool, } pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ModifyArgs) -> Result<(), CliError> { - let piped = try_read_stdin()?; - if let Some(input) = piped.as_deref() - && !is_edit_mode(input) - { - return Err(CliError::Other( - "stdin doesn't look like edit mode (===BEFORE===/===AFTER===). \ - Use `flicknote replace --section
` for section overwrite." - .into(), - )); - } - let (before, after) = match piped.as_deref() { - Some(input) => { - let (before, after) = parse_edit_input(input)?; - (Some(before), Some(after)) - } - None => (None, None), - }; let flagged = if args.flagged { Some(true) } else if args.unflagged { @@ -55,9 +37,9 @@ pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ModifyArgs) -> Result< let result: NoteMutationResult = daemon .call(AppRequest::NoteModify(NoteModifyInput { id: args.id.clone(), - before, - after, - section: args.section.clone(), + before: None, + after: None, + section: None, project: args.project.clone(), flagged, })) @@ -67,15 +49,3 @@ pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ModifyArgs) -> Result< print_section_tree(&result.sections); Ok(()) } - -#[cfg(test)] -mod tests { - use super::super::util::classify_stdin_buf; - - #[test] - fn test_classify_stdin_buf_via_util() { - assert_eq!(classify_stdin_buf(" \n "), None); - assert_eq!(classify_stdin_buf("x"), Some("x".to_string())); - assert_eq!(classify_stdin_buf(" foo "), Some(" foo".to_string())); - } -} diff --git a/flicknote-cli/src/commands/rename.rs b/flicknote-cli/src/commands/rename.rs deleted file mode 100644 index 89d4457..0000000 --- a/flicknote-cli/src/commands/rename.rs +++ /dev/null @@ -1,35 +0,0 @@ -use clap::Args; -use flicknote_core::error::CliError; -use flicknote_core::services::dto::NoteMutationResult; -use flicknote_sync::ipc::{AppRequest, DaemonClient}; - -use super::util::{display_summary_id, print_section_tree}; - -#[derive(Args)] -pub(crate) struct RenameArgs { - /// Note ID. Use the numeric short ID shown in list/detail. Full UUIDs are also accepted for compatibility. - id: String, - /// Section heading to rename (case-insensitive contains match) - #[arg(short = 's', long = "section")] - section: String, - /// New heading text (without # prefix — level is preserved) - name: String, -} - -pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &RenameArgs) -> Result<(), CliError> { - let result: NoteMutationResult = daemon - .call(AppRequest::NoteRenameSection { - id: args.id.clone(), - section: args.section.clone(), - name: args.name.clone(), - }) - .await?; - println!( - "Renamed section {} → '{}' in note {}.\n", - args.section, - args.name, - display_summary_id(&result.note) - ); - print_section_tree(&result.sections); - Ok(()) -} diff --git a/flicknote-cli/src/commands/replace.rs b/flicknote-cli/src/commands/replace.rs deleted file mode 100644 index a2b2a62..0000000 --- a/flicknote-cli/src/commands/replace.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! `flicknote replace` — overwrite a whole section. - -use clap::Args; -use flicknote_core::error::CliError; -use flicknote_core::services::dto::NoteMutationResult; -use flicknote_sync::ipc::{AppRequest, DaemonClient}; - -use super::util::{display_summary_id, print_section_tree, try_read_stdin}; - -const REPLACE_HELP: &str = include_str!("../help/replace.md"); - -#[derive(Args)] -#[command(after_help = REPLACE_HELP)] -pub(crate) struct ReplaceArgs { - /// Note ID. Use the numeric short ID shown in list/detail. Full UUIDs are also accepted for compatibility. - id: String, - /// Replace the named section (stdin must start with a heading) - #[arg(short = 's', long = "section")] - section: String, -} - -pub(crate) async fn run(daemon: &DaemonClient<'_>, args: &ReplaceArgs) -> Result<(), CliError> { - let Some(content) = try_read_stdin()? else { - return Err(CliError::Other( - "--section requires content from stdin".into(), - )); - }; - let result: NoteMutationResult = daemon - .call(AppRequest::NoteReplaceSection { - id: args.id.clone(), - section: args.section.clone(), - content, - }) - .await?; - println!( - "Replaced section in note {}.\n", - display_summary_id(&result.note) - ); - print_section_tree(&result.sections); - Ok(()) -} - -#[cfg(test)] -mod tests { - use flicknote_core::services::markdown::{parse_markdown, replace_entire_section}; - use flicknote_core::services::sections::{content_starts_with_heading, find_section}; - - #[test] - fn test_replace_section_setext_atx() { - assert!(content_starts_with_heading("My Section\n==========")); - assert!(content_starts_with_heading("My Section\n----------")); - } - - #[test] - fn test_replace_section_preserves_frontmatter_outside_section_scope() { - let content = "---\ncustom: keep\n---\n\n## Target\nold body\n\n## Other\nother body"; - let document = parse_markdown(content); - let heading = document - .headings - .iter() - .find(|heading| heading.text == "Target") - .unwrap(); - let bounds = find_section(&document, &heading.id, "note-id").unwrap(); - let updated = - replace_entire_section(content, bounds.start, bounds.end, "## Target\nnew body"); - assert!(updated.starts_with("---\ncustom: keep\n---")); - assert!(updated.contains("## Target\nnew body")); - assert!(updated.contains("## Other\nother body")); - } -} diff --git a/flicknote-cli/src/commands/skill.rs b/flicknote-cli/src/commands/skill.rs index fa449a5..538c67a 100644 --- a/flicknote-cli/src/commands/skill.rs +++ b/flicknote-cli/src/commands/skill.rs @@ -37,10 +37,7 @@ fn install_default() -> Result<(), CliError> { } fn install_to_homes(home: &Path) -> Result, CliError> { - install_to_bases([ - home.join(".agents").join("skills"), - home.join(".claude").join("skills"), - ]) + install_to_bases([home.join(".agents").join("skills")]) } fn install_to_bases(bases: I) -> Result, CliError> @@ -65,44 +62,35 @@ mod tests { use super::*; #[test] - fn install_to_bases_creates_flicknote_skill_files() { + fn install_to_bases_writes_only_the_standard_agent_skill_destination() { let temp = tempfile::tempdir().expect("temp dir"); let agents = temp.path().join(".agents").join("skills"); - let claude = temp.path().join(".claude").join("skills"); - let installed = install_to_bases([agents.clone(), claude.clone()]).expect("install skill"); + let installed = install_to_bases([agents.clone()]).expect("install skill"); - assert_eq!( - installed, - vec![ - agents.join("flicknote").join("SKILL.md"), - claude.join("flicknote").join("SKILL.md"), - ] - ); + assert_eq!(installed, vec![agents.join("flicknote").join("SKILL.md")]); assert_eq!( fs::read_to_string(agents.join("flicknote").join("SKILL.md")).expect("agents skill"), FLICKNOTE_SKILL ); - assert_eq!( - fs::read_to_string(claude.join("flicknote").join("SKILL.md")).expect("claude skill"), - FLICKNOTE_SKILL - ); } #[test] - fn install_to_homes_creates_missing_skill_roots() { + fn install_to_homes_leaves_existing_claude_skill_untouched() { let temp = tempfile::tempdir().expect("temp dir"); + let claude_skill = temp.path().join(".claude/skills/flicknote/SKILL.md"); + fs::create_dir_all(claude_skill.parent().unwrap()).expect("claude skill dir"); + fs::write(&claude_skill, "user-managed skill").expect("existing claude skill"); install_to_homes(temp.path()).expect("install skill"); - assert!( - temp.path() - .join(".agents/skills/flicknote/SKILL.md") - .exists() + assert_eq!( + fs::read_to_string(claude_skill).unwrap(), + "user-managed skill" ); assert!( temp.path() - .join(".claude/skills/flicknote/SKILL.md") + .join(".agents/skills/flicknote/SKILL.md") .exists() ); } diff --git a/flicknote-cli/src/commands/sync.rs b/flicknote-cli/src/commands/sync.rs index 1219bd6..96e16f9 100644 --- a/flicknote-cli/src/commands/sync.rs +++ b/flicknote-cli/src/commands/sync.rs @@ -339,6 +339,6 @@ mod tests { assert!(line.contains("pid 42")); assert!(line.contains(env!("CARGO_PKG_VERSION"))); - assert!(line.contains("protocol 2")); + assert!(line.contains("protocol 3")); } } diff --git a/flicknote-cli/src/commands/util.rs b/flicknote-cli/src/commands/util.rs index 5be0520..572aa28 100644 --- a/flicknote-cli/src/commands/util.rs +++ b/flicknote-cli/src/commands/util.rs @@ -1,6 +1,5 @@ use flicknote_core::error::CliError; -use flicknote_core::services::dto::{NoteSummary, SectionDto}; -use flicknote_core::types::Note; +use flicknote_core::services::dto::{NoteRecord, NoteSummary, SectionDto}; use flicknote_sync::ipc::{AppRequest, DaemonClient}; use std::io::{IsTerminal, Read}; @@ -10,12 +9,11 @@ pub(crate) fn display_summary_id(note: &NoteSummary) -> String { .unwrap_or_else(|| note.uuid.clone()) } -pub(crate) fn note_json(note: &Note, project_name: Option<&str>) -> serde_json::Value { +pub(crate) fn note_json(note: &NoteRecord, project_name: Option<&str>) -> serde_json::Value { serde_json::json!({ "id": note.short_id, "uuid": note.id, - "type": note.r#type, - "status": note.status, + "type": note.note_type, "title": note.title, "project": project_name, "project_id": note.project_id, @@ -35,7 +33,7 @@ pub(crate) async fn note_summaries_json( ) -> Result, CliError> { let mut values = Vec::with_capacity(notes.len()); for summary in notes { - let note: Note = daemon + let note: NoteRecord = daemon .call(AppRequest::NoteRecord { id: summary.uuid.clone(), archived, @@ -142,24 +140,3 @@ pub(crate) fn read_stdin_required() -> Result { } Ok(trimmed) } - -/// Read optional stdin content. Returns `Ok(None)` when stdin is a terminal or empty. -pub(crate) fn try_read_stdin() -> Result, CliError> { - if std::io::stdin().is_terminal() { - return Ok(None); - } - let mut buf = String::new(); - std::io::stdin().read_to_string(&mut buf)?; - Ok(classify_stdin_buf(&buf)) -} - -/// Classify a freshly-read stdin buffer. Pure helper, testable without a TTY. -pub(crate) fn classify_stdin_buf(buf: &str) -> Option { - let trimmed = buf.trim_end_matches(|c: char| c.is_ascii_whitespace()); - let trimmed = trimmed.trim_end_matches(' '); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} diff --git a/flicknote-cli/src/help/detail.md b/flicknote-cli/src/help/detail.md index 4c3d3c0..290bbad 100644 --- a/flicknote-cli/src/help/detail.md +++ b/flicknote-cli/src/help/detail.md @@ -4,5 +4,5 @@ Examples: flicknote detail 123 --json flicknote detail 123 --archived -Use `--tree` to see section IDs before running `content`, `modify`, `replace`, -`insert`, `rename`, or `delete --section`. +Use `--tree` to see section IDs before running `content` or other supported +human workflows. Structured section editing is available through FlickNote MCP. diff --git a/flicknote-cli/src/help/insert.md b/flicknote-cli/src/help/insert.md deleted file mode 100644 index 4308eb7..0000000 --- a/flicknote-cli/src/help/insert.md +++ /dev/null @@ -1,9 +0,0 @@ -Content is read from stdin and inserted next to the selected section. -Section IDs come from `flicknote detail --tree`. - -Example: -cat <<'EOF' | flicknote insert 123 --after 3K -## New section - -Add the new section body here. -EOF diff --git a/flicknote-cli/src/help/modify.md b/flicknote-cli/src/help/modify.md index 23eab70..43b4e75 100644 --- a/flicknote-cli/src/help/modify.md +++ b/flicknote-cli/src/help/modify.md @@ -1,24 +1,16 @@ -Edit mode reads one exact replacement block from stdin: - ===BEFORE=== - old text exactly as it appears - ===AFTER=== - new text +`modify` changes note metadata for a human CLI workflow. Provide at least one +metadata option; `--project` may be combined with either flagged option. -Rules: - - Exact match, whitespace-sensitive. - - Unique match required; add surrounding context if the text appears more than once. - - Single block per call. - - `--section` scopes the match to the full section, including its heading. - - For a section overwrite, use `flicknote replace --section `. +Options: + - `--project ` moves the note to a project. + - `--flagged` marks the note as flagged. + - `--unflagged` removes the flagged state. + - `--flagged` and `--unflagged` cannot be used together. Examples: flicknote modify 123 --project work - flicknote modify 123 --flagged + flicknote modify 123 --project work --flagged + flicknote modify 123 --unflagged -Apply an exact replacement from stdin: -cat <<'EOF' | flicknote modify 123 -===BEFORE=== -old text exactly as it appears -===AFTER=== -new text -EOF +Content and section edits are available through the structured FlickNote MCP +interface; this command does not read replacement documents from stdin. diff --git a/flicknote-cli/src/help/replace.md b/flicknote-cli/src/help/replace.md deleted file mode 100644 index 1d7a8ad..0000000 --- a/flicknote-cli/src/help/replace.md +++ /dev/null @@ -1,17 +0,0 @@ -`flicknote replace` overwrites one whole section subtree, including its heading. -For precision edits, use `flicknote modify `. - -Rules: - - Content is read from stdin. - - `--section` is required. - - Stdin must start with a heading. - - Section heading level is capped at the original section level. - - Project and flagged metadata are changed with `flicknote modify`. - - To replace a whole note, archive it and create a new note. - -Examples: -cat <<'EOF' | flicknote replace 123 --section 3K -## New heading - -Replace the selected section with this text. -EOF diff --git a/flicknote-cli/src/help/root.md b/flicknote-cli/src/help/root.md index aba2142..e70406b 100644 --- a/flicknote-cli/src/help/root.md +++ b/flicknote-cli/src/help/root.md @@ -5,6 +5,7 @@ Run `flicknote --help` for exact flags and examples. Common workflows: flicknote add "Meeting notes" --project work flicknote upload file.pdf --project work + flicknote import notes/ --project work flicknote find "keyword" flicknote find "::topic::AI::person::瓜子" flicknote topic list @@ -12,13 +13,12 @@ Common workflows: flicknote source flicknote detail --tree flicknote content --section + flicknote modify --project work flicknote share flicknote unshare flicknote project share flicknote project unshare - flicknote gateway request --path /healthz - cat edit.md | flicknote modify - cat section.md | flicknote replace --section flicknote mcp +The Gateway command is for internal development and maintenance requests. Use numeric note IDs from `flicknote list`. diff --git a/flicknote-cli/src/main.rs b/flicknote-cli/src/main.rs index 4732b14..7a09810 100644 --- a/flicknote-cli/src/main.rs +++ b/flicknote-cli/src/main.rs @@ -33,7 +33,7 @@ enum Commands { Upload(commands::upload::UploadArgs), /// Append content to an existing note Append(commands::append::AppendArgs), - /// Delete a note (soft-delete) or remove a section + /// Delete (archive) a note Delete(commands::delete::DeleteArgs), /// Edit a note in $EDITOR, or create a new note from editor Edit(commands::edit::EditArgs), @@ -73,13 +73,7 @@ enum Commands { Skill(commands::skill::SkillArgs), /// Import markdown files as notes Import(commands::import::ImportArgs), - /// Rename a section heading in a note - Rename(commands::rename::RenameArgs), - /// Insert content before or after a section - Insert(commands::insert::InsertArgs), - /// Replace a whole section — for precision edits use modify - Replace(commands::replace::ReplaceArgs), - /// Modify note via ===BEFORE===/===AFTER=== blocks and/or update metadata + /// Modify note metadata Modify(commands::modify::ModifyArgs), /// Open a note in the browser Open(commands::open::OpenArgs), @@ -150,9 +144,6 @@ async fn dispatch(cli: &Cli, daemon: &DaemonClient<'_>) -> Result<(), CliError> Commands::Share(args) => commands::share::run_note(daemon, args).await, Commands::Unshare(args) => commands::share::run_unshare_note(daemon, args).await, Commands::Project(args) => commands::project::run(daemon, args).await, - Commands::Rename(args) => commands::rename::run(daemon, args).await, - Commands::Insert(args) => commands::insert::run(daemon, args).await, - Commands::Replace(args) => commands::replace::run(daemon, args).await, Commands::Modify(args) => commands::modify::run(daemon, args).await, Commands::Open(args) => commands::open::run(daemon, args).await, Commands::Import(args) => commands::import::run(daemon, args).await, diff --git a/flicknote-cli/src/main_tests.rs b/flicknote-cli/src/main_tests.rs index ed4c9ea..adc3d76 100644 --- a/flicknote-cli/src/main_tests.rs +++ b/flicknote-cli/src/main_tests.rs @@ -80,19 +80,34 @@ fn note_type_filters_accept_meeting_and_reject_voice() { } #[test] -fn replace_requires_section() { - assert!(Cli::try_parse_from(["flicknote", "replace", "1"]).is_err()); - assert!(Cli::try_parse_from(["flicknote", "replace", "1", "--section", "a1"]).is_ok()); +fn agent_content_mutation_commands_are_not_cli_commands() { + for command in ["replace", "insert", "rename"] { + assert!( + Cli::try_parse_from(["flicknote", command, "1"]).is_err(), + "accepted removed command {command}" + ); + } + assert!(Cli::try_parse_from(["flicknote", "delete", "1", "--section", "a1"]).is_err()); +} + +#[test] +fn modify_requires_metadata_and_accepts_valid_combinations() { + assert!(Cli::try_parse_from(["flicknote", "modify", "1"]).is_err()); + assert!(Cli::try_parse_from(["flicknote", "modify", "1", "--project", "work"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "modify", "1", "--flagged"]).is_ok()); + assert!(Cli::try_parse_from(["flicknote", "modify", "1", "--unflagged"]).is_ok()); + assert!( + Cli::try_parse_from(["flicknote", "modify", "1", "--project", "work", "--flagged"]).is_ok() + ); + assert!(Cli::try_parse_from(["flicknote", "modify", "1", "--flagged", "--unflagged"]).is_err()); } #[test] -fn replace_rejects_metadata_flags() { - for flag in ["--project", "--flagged", "--unflagged"] { - let mut argv = vec!["flicknote", "replace", "1", "--section", "a1", flag]; - if flag == "--project" { - argv.push("work"); - } - assert!(Cli::try_parse_from(argv).is_err(), "accepted {flag}"); +fn modify_rejects_content_editing_and_section_arguments() { + for argument in ["--before", "--after", "--section"] { + let mut argv = vec!["flicknote", "modify", "1", "--project", "work", argument]; + argv.push("value"); + assert!(Cli::try_parse_from(argv).is_err(), "accepted {argument}"); } } diff --git a/flicknote-cli/src/main_tests/mcp.rs b/flicknote-cli/src/main_tests/mcp.rs index 02cca88..82c2b48 100644 --- a/flicknote-cli/src/main_tests/mcp.rs +++ b/flicknote-cli/src/main_tests/mcp.rs @@ -197,6 +197,26 @@ async fn seeded_backend(config: &Config) -> (Arc, String, ) .unwrap(); drop(writer); + backend + .set_note_extractions(¬e_uuid, "::topic", &["AI".to_string()]) + .await + .unwrap(); + backend + .set_note_extractions(¬e_uuid, "::person", &["Ada Lovelace".to_string()]) + .await + .unwrap(); + backend + .set_note_extractions(¬e_uuid, "::company", &["OpenAI".to_string()]) + .await + .unwrap(); + backend + .set_note_extractions(¬e_uuid, "::location", &["London".to_string()]) + .await + .unwrap(); + backend + .set_note_extractions(¬e_uuid, "::product", &["ChatGPT".to_string()]) + .await + .unwrap(); let no_source_id = uuid::Uuid::new_v4().to_string(); backend .insert_note(&InsertNoteReq { @@ -296,6 +316,29 @@ fn assert_json_does_not_contain_string(value: &serde_json::Value, excluded: &str } } +fn assert_json_does_not_contain_key(value: &serde_json::Value, excluded: &str) { + match value { + serde_json::Value::Array(values) => { + for value in values { + assert_json_does_not_contain_key(value, excluded); + } + } + serde_json::Value::Object(values) => { + assert!( + !values.contains_key(excluded), + "unexpected internal field {excluded}" + ); + for value in values.values() { + assert_json_does_not_contain_key(value, excluded); + } + } + serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) => {} + } +} + #[tokio::test] async fn mcp_server_exposes_stable_tool_contract() { let mut harness = McpHarness::start().await; @@ -333,7 +376,34 @@ async fn mcp_server_exposes_stable_tool_contract() { assert_eq!(schema["properties"]["id"]["type"], "integer"); } assert!(!tool["outputSchema"].to_string().contains("uuid")); + assert!(!tool["outputSchema"].to_string().contains("status")); } + let topics = tools + .iter() + .find(|tool| tool["name"] == "topic_list") + .unwrap(); + assert_eq!( + topics["outputSchema"]["properties"]["topics"]["type"], + "array" + ); + assert_eq!( + topics["outputSchema"]["properties"]["topics"]["items"]["type"], + "string" + ); + let entities = tools + .iter() + .find(|tool| tool["name"] == "entity_list") + .unwrap(); + let entity_item = &entities["outputSchema"]["properties"]["entities"]["items"]; + let entity_properties = entity_item + .get("$ref") + .and_then(serde_json::Value::as_str) + .and_then(|reference| reference.strip_prefix("#/$defs/")) + .map(|name| &entities["outputSchema"]["$defs"][name]["properties"]) + .unwrap_or(&entity_item["properties"]); + assert!(entity_properties.get("value").is_some()); + assert!(entity_properties.get("type").is_some()); + let project_get = tools .iter() .find(|tool| tool["name"] == "project_get") @@ -569,6 +639,96 @@ async fn mcp_note_source_output_schema_advertises_all_views() { } } +#[tokio::test] +async fn mcp_discovery_returns_object_wrapped_typed_results() { + let mut harness = McpHarness::start().await; + + let topics = harness.call("topic_list", serde_json::json!({})).await; + assert_eq!(topics["result"]["isError"], false); + assert_eq!( + topics["result"]["structuredContent"], + serde_json::json!({ + "topics": ["AI"] + }) + ); + + let entities = harness.call("entity_list", serde_json::json!({})).await; + assert_eq!(entities["result"]["isError"], false); + assert_eq!( + entities["result"]["structuredContent"]["entities"], + serde_json::json!([ + { "value": "Ada Lovelace", "type": "person" }, + { "value": "OpenAI", "type": "company" }, + { "value": "London", "type": "location" }, + { "value": "ChatGPT", "type": "product" } + ]) + ); + + let people = harness + .call("entity_list", serde_json::json!({ "type": "person" })) + .await; + assert_eq!(people["result"]["isError"], false); + assert_eq!( + people["result"]["structuredContent"]["entities"], + serde_json::json!([ + { "value": "Ada Lovelace", "type": "person" } + ]) + ); +} + +#[tokio::test] +async fn mcp_section_mutations_remain_behaviorally_available() { + let mut harness = McpHarness::start().await; + let section = harness.alpha_id.clone(); + + let inserted = harness + .call( + "note_insert", + serde_json::json!({ + "id": 42, + "section": section, + "position": "before", + "content": "## Inserted\n\nInserted body." + }), + ) + .await; + assert_eq!(inserted["result"]["isError"], false); + + let renamed = harness + .call( + "note_rename_section", + serde_json::json!({ "id": 42, "section": harness.alpha_id, "name": "Renamed" }), + ) + .await; + assert_eq!(renamed["result"]["isError"], false); + let renamed_section = renamed["result"]["structuredContent"]["sections"] + .as_array() + .unwrap() + .iter() + .find(|section| section["title"] == "Renamed") + .and_then(|section| section["id"].as_str()) + .expect("renamed section id") + .to_string(); + + let deleted = harness + .call( + "note_delete_section", + serde_json::json!({ "id": 42, "section": renamed_section }), + ) + .await; + assert_eq!(deleted["result"]["isError"], false); + + let fetched = harness + .call("note_get", serde_json::json!({ "id": 42 })) + .await; + let content = fetched["result"]["structuredContent"]["content"] + .as_str() + .unwrap(); + assert!(content.contains("## Inserted")); + assert!(content.contains("## Beta")); + assert!(!content.contains("## Renamed")); +} + #[tokio::test] async fn mcp_note_queries_use_short_ids_and_hide_uuid() { let mut harness = McpHarness::start().await; @@ -582,11 +742,13 @@ async fn mcp_note_queries_use_short_ids_and_hide_uuid() { 2 ); assert_json_does_not_contain_string(&listed["result"]["structuredContent"], &harness.note_uuid); + assert_json_does_not_contain_key(&listed["result"]["structuredContent"], "status"); let fetched = harness .call("note_get", serde_json::json!({ "id": 42 })) .await; assert!(fetched["result"]["structuredContent"].get("uuid").is_none()); + assert_json_does_not_contain_key(&fetched["result"]["structuredContent"], "status"); assert!( fetched["result"]["structuredContent"] .get("project_id") @@ -627,6 +789,7 @@ async fn mcp_note_mutations_and_lifecycle_route_through_daemon() { modified["result"]["structuredContent"]["note"]["flagged"], true ); + assert_json_does_not_contain_key(&modified["result"]["structuredContent"], "status"); let section = harness.alpha_id.clone(); harness .call( @@ -667,6 +830,7 @@ async fn mcp_note_mutations_and_lifecycle_route_through_daemon() { ) .await; assert_eq!(added["result"]["isError"], false); + assert_json_does_not_contain_key(&added["result"]["structuredContent"], "status"); let archived = harness .call("note_archive", serde_json::json!({ "id": 42 })) .await; diff --git a/flicknote-cli/src/mcp/dto.rs b/flicknote-cli/src/mcp/dto.rs index ea2b81c..661b2f3 100644 --- a/flicknote-cli/src/mcp/dto.rs +++ b/flicknote-cli/src/mcp/dto.rs @@ -8,14 +8,50 @@ use flicknote_core::services::source::SourceResult; use rmcp::handler::server::tool::schema_for_output; use rmcp::model::JsonObject; use rmcp::schemars::{JsonSchema, Schema, SchemaGenerator}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(super) enum McpEntityType { + Person, + Company, + Location, + Product, +} + +impl McpEntityType { + pub(super) const fn extraction_key(self) -> &'static str { + match self { + Self::Person => "::person", + Self::Company => "::company", + Self::Location => "::location", + Self::Product => "::product", + } + } +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct McpTopicListResult { + pub topics: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct McpEntity { + pub value: String, + #[serde(rename = "type")] + pub entity_type: McpEntityType, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct McpEntityListResult { + pub entities: Vec, +} #[derive(Debug, Serialize, JsonSchema)] pub(super) struct McpNoteSummary { pub id: Option, #[serde(rename = "type")] pub note_type: String, - pub status: String, pub title: Option, pub project: Option, pub topics: Vec, @@ -41,7 +77,6 @@ impl From for McpNoteSummary { Self { id: note.short_id, note_type: note.note_type, - status: note.status, title: note.title, project: note.project, topics: note.topics, diff --git a/flicknote-cli/src/mcp/note_tools.rs b/flicknote-cli/src/mcp/note_tools.rs index 3f762bd..daf40c0 100644 --- a/flicknote-cli/src/mcp/note_tools.rs +++ b/flicknote-cli/src/mcp/note_tools.rs @@ -1,4 +1,6 @@ use flicknote_core::services::dto::ExtractionFilterDto; + +use super::dto::McpEntityType; use flicknote_core::services::source::SourceView; use rmcp::schemars::JsonSchema; use serde::de::{self, MapAccess, SeqAccess, Visitor}; @@ -105,6 +107,15 @@ fn default_limit() -> u32 { 20 } +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct TopicListParams {} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct EntityListParams { + #[serde(rename = "type")] + pub entity_type: Option, +} + #[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] #[schemars(rename = "NoteType")] diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index bc7e107..8402058 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use flicknote_core::TOPIC_EXTRACTION_KEY; use flicknote_core::config::Config; use flicknote_core::error::CliError; use flicknote_core::services::dto::{ @@ -19,8 +20,9 @@ use rmcp::{Json, ServerHandler, ServiceExt, tool, tool_handler, tool_router}; use serde::Serialize; use super::dto::{ - McpNoteArchiveResult, McpNoteDetail, McpNoteListResult, McpNoteMutationResult, McpNoteSummary, - McpProjectDto, McpProjectListResult, McpSourceResult, source_output_schema, + McpEntity, McpEntityListResult, McpEntityType, McpNoteArchiveResult, McpNoteDetail, + McpNoteListResult, McpNoteMutationResult, McpNoteSummary, McpProjectDto, McpProjectListResult, + McpSourceResult, McpTopicListResult, source_output_schema, }; use super::error::tool_error; use super::note_tools::*; @@ -28,7 +30,8 @@ use super::project_tools::*; use crate::commands::open::SystemBrowserOpener; #[cfg(test)] -pub(crate) const EXPECTED_TOOLS: [&str; 25] = [ +pub(crate) const EXPECTED_TOOLS: [&str; 27] = [ + "entity_list", "note_add", "note_append", "note_archive", @@ -54,6 +57,7 @@ pub(crate) const EXPECTED_TOOLS: [&str; 25] = [ "project_modify", "project_share", "project_unshare", + "topic_list", ]; #[derive(Debug, Serialize, JsonSchema)] @@ -170,6 +174,66 @@ impl FlickNoteMcp { ) } + #[tool( + name = "topic_list", + description = "List known topics extracted from active notes.", + annotations(read_only_hint = true) + )] + async fn topic_list( + &self, + Parameters(_params): Parameters, + ) -> Result, CallToolResult> { + structured( + self.call::>(AppRequest::ExtractionValues { + keys: vec![TOPIC_EXTRACTION_KEY.to_string()], + archived: false, + }) + .await + .map(|topics| McpTopicListResult { topics }), + ) + } + + #[tool( + name = "entity_list", + description = "List known typed entities extracted from active notes, optionally filtered by type.", + annotations(read_only_hint = true) + )] + async fn entity_list( + &self, + Parameters(params): Parameters, + ) -> Result, CallToolResult> { + let entity_types = params.entity_type.map_or_else( + || { + vec![ + McpEntityType::Person, + McpEntityType::Company, + McpEntityType::Location, + McpEntityType::Product, + ] + }, + |entity_type| vec![entity_type], + ); + let result: Result = async { + let mut entities = Vec::new(); + for entity_type in entity_types { + let values = self + .call::>(AppRequest::ExtractionValues { + keys: vec![entity_type.extraction_key().to_string()], + archived: false, + }) + .await?; + entities.extend( + values + .into_iter() + .map(|value| McpEntity { value, entity_type }), + ); + } + Ok(McpEntityListResult { entities }) + } + .await; + structured(result) + } + #[tool( name = "note_get", description = "Get one note with editable content, metadata, extractions, and section tree.", diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index 706190c..34c5e29 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -305,7 +305,6 @@ fn fake_note_summary() -> flicknote_core::services::dto::NoteSummary { short_id: Some(77), uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), note_type: "normal".to_string(), - status: "synced".to_string(), title: Some("Adapter note".to_string()), project_id: None, project: None, @@ -318,7 +317,10 @@ fn fake_note_summary() -> flicknote_core::services::dto::NoteSummary { } } -fn assert_legacy_note_shape(note: &serde_json::Value, project: &serde_json::Value) { +fn assert_note_shape_without_internal_status( + note: &serde_json::Value, + project: &serde_json::Value, +) { let object = note.as_object().unwrap(); let keys = object .keys() @@ -334,7 +336,6 @@ fn assert_legacy_note_shape(note: &serde_json::Value, project: &serde_json::Valu "is_flagged", "project", "project_id", - "status", "summary", "title", "type", @@ -726,13 +727,13 @@ async fn cli_json_commands_preserve_the_existing_machine_contracts() { let _daemon = spawn_test_daemon(&config_root, &data_root); let listed = run_cli_json(&config_root, &data_root, &["list", "--json"]); - assert_legacy_note_shape(&listed[0], &serde_json::Value::Null); + assert_note_shape_without_internal_status(&listed[0], &serde_json::Value::Null); let found = run_cli_json(&config_root, &data_root, &["find", "stored", "--json"]); - assert_legacy_note_shape(&found[0], &serde_json::Value::Null); + assert_note_shape_without_internal_status(&found[0], &serde_json::Value::Null); let detailed = run_cli_json(&config_root, &data_root, &["detail", ¬e_id, "--json"]); - assert_legacy_note_shape( + assert_note_shape_without_internal_status( &detailed, &serde_json::Value::String("Legacy project".to_string()), ); diff --git a/flicknote-core/src/services/dto.rs b/flicknote-core/src/services/dto.rs index 7d27b05..342287a 100644 --- a/flicknote-core/src/services/dto.rs +++ b/flicknote-core/src/services/dto.rs @@ -62,7 +62,6 @@ pub struct NoteSummary { pub uuid: String, #[serde(rename = "type")] pub note_type: String, - pub status: String, pub title: Option, pub project_id: Option, pub project: Option, @@ -74,6 +73,45 @@ pub struct NoteSummary { pub deleted_at: Option, } +/// A status-free note record used at the daemon boundary for CLI detail JSON. +/// +/// This intentionally projects the storage note instead of serializing the +/// storage entity directly. Internal synchronization fields and data that the +/// supported record consumer does not need stay inside the daemon. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NoteRecord { + pub id: String, + pub short_id: Option, + #[serde(rename = "type")] + pub note_type: String, + pub title: Option, + pub content: Option, + pub summary: Option, + pub is_flagged: Option, + pub project_id: Option, + pub created_at: Option, + pub updated_at: Option, + pub deleted_at: Option, +} + +impl From for NoteRecord { + fn from(note: crate::types::Note) -> Self { + Self { + id: note.id, + short_id: note.short_id, + note_type: note.r#type, + title: note.title, + content: note.content, + summary: note.summary, + is_flagged: note.is_flagged, + project_id: note.project_id, + created_at: note.created_at, + updated_at: note.updated_at, + deleted_at: note.deleted_at, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct SectionDto { pub id: String, @@ -251,7 +289,6 @@ mod tests { short_id: Some(42), uuid: "note-uuid".to_string(), note_type: "normal".to_string(), - status: "synced".to_string(), title: None, project_id: None, project: None, diff --git a/flicknote-core/src/services/note.rs b/flicknote-core/src/services/note.rs index cd49a20..7116f3d 100644 --- a/flicknote-core/src/services/note.rs +++ b/flicknote-core/src/services/note.rs @@ -578,7 +578,6 @@ impl<'a> NoteService<'a> { short_id: note.short_id, uuid: note.id, note_type: note.r#type, - status: note.status, title: note.title, project_id: note.project_id, project, diff --git a/flicknote-sync/src/app/note.rs b/flicknote-sync/src/app/note.rs index 5f10bcf..708ad79 100644 --- a/flicknote-sync/src/app/note.rs +++ b/flicknote-sync/src/app/note.rs @@ -154,7 +154,7 @@ async fn note_record( app.db.find_note(&id).await } .map_err(Application::db_error)?; - Ok(AppResponse::NoteRecord(note)) + Ok(AppResponse::NoteRecord(note.into())) } async fn open_note(app: &Application, id: &str) -> Result { diff --git a/flicknote-sync/src/ipc/mod.rs b/flicknote-sync/src/ipc/mod.rs index 2d3cae9..90e692e 100644 --- a/flicknote-sync/src/ipc/mod.rs +++ b/flicknote-sync/src/ipc/mod.rs @@ -4,13 +4,13 @@ use std::path::PathBuf; use flicknote_core::config::Config; use flicknote_core::services::dto::{ InsertPosition, NoteAddInput, NoteArchiveResult, NoteCountInput, NoteDetail, NoteFindInput, - NoteListInput, NoteModifyInput, NoteMutationResult, NoteSectionResult, NoteSummary, OpenResult, - ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, + NoteListInput, NoteModifyInput, NoteMutationResult, NoteRecord, NoteSectionResult, NoteSummary, + OpenResult, ProjectAddInput, ProjectDto, ProjectModifyInput, ShareResult, UnshareResult, }; use flicknote_core::services::editable_document::EditableSaveResult; use flicknote_core::services::error::ServiceError; use flicknote_core::services::source::{SourceResult, SourceView}; -use flicknote_core::types::{Note, Project}; +use flicknote_core::types::Project; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixListener; diff --git a/flicknote-sync/src/ipc/protocol.rs b/flicknote-sync/src/ipc/protocol.rs index 4fed718..c3a8a94 100644 --- a/flicknote-sync/src/ipc/protocol.rs +++ b/flicknote-sync/src/ipc/protocol.rs @@ -1,6 +1,6 @@ use super::*; -pub const PROTOCOL_VERSION: u16 = 2; +pub const PROTOCOL_VERSION: u16 = 3; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerInfo { @@ -191,7 +191,7 @@ pub enum AppResponse { NoteCount { count: u64 }, NoteDetail(NoteDetail), EditableDocument(EditableDocument), - NoteRecord(Note), + NoteRecord(NoteRecord), NoteSection(NoteSectionResult), NoteMutation(NoteMutationResult), EditableSave(EditableSaveResult), @@ -232,7 +232,7 @@ app_result!(NoteSummary, AppResponse::NoteSummary); app_result!(Vec, AppResponse::NoteSummaries); app_result!(NoteDetail, AppResponse::NoteDetail); app_result!(EditableDocument, AppResponse::EditableDocument); -app_result!(Note, AppResponse::NoteRecord); +app_result!(NoteRecord, AppResponse::NoteRecord); app_result!(NoteSectionResult, AppResponse::NoteSection); app_result!(NoteMutationResult, AppResponse::NoteMutation); app_result!(EditableSaveResult, AppResponse::EditableSave); diff --git a/flicknote-sync/src/ipc/tests.rs b/flicknote-sync/src/ipc/tests.rs index 7da9ffc..7ea0799 100644 --- a/flicknote-sync/src/ipc/tests.rs +++ b/flicknote-sync/src/ipc/tests.rs @@ -65,7 +65,7 @@ fn socket_path_lives_in_data_dir() { #[test] fn versioned_health_and_app_requests_have_stable_contracts() { - assert_eq!(PROTOCOL_VERSION, 2); + assert_eq!(PROTOCOL_VERSION, 3); let health = DaemonRequest::Health { protocol: PROTOCOL_VERSION, }; @@ -232,13 +232,13 @@ async fn health_rejects_unexpected_daemon_responses() { } #[tokio::test] -async fn protocol_v2_client_rejects_protocol_v1_server_info() { +async fn protocol_v3_client_rejects_protocol_v2_server_info() { let directory = tempfile::tempdir().unwrap(); let config = test_config(directory.path()); let server = serve_response( &config, DaemonResponse::ServerInfo(ServerInfo { - protocol: 1, + protocol: 2, version: "legacy".to_string(), }), ) diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 30054c0..0b3825a 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -105,6 +105,29 @@ fn test_app(db: Arc) -> Application { app_with_creator(db, Arc::new(DetachedCreator)) } +fn assert_no_status_field(value: &serde_json::Value) { + match value { + serde_json::Value::Array(values) => { + for value in values { + assert_no_status_field(value); + } + } + serde_json::Value::Object(values) => { + assert!( + !values.contains_key("status"), + "internal status leaked: {value}" + ); + for value in values.values() { + assert_no_status_field(value); + } + } + serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) => {} + } +} + #[tokio::test] async fn app_preserves_created_identity_when_editor_or_attachment_summary_fails() { let directory = tempfile::tempdir().unwrap(); @@ -169,6 +192,7 @@ async fn app_routes_note_list_and_append_through_services() { }; assert_eq!(notes.len(), 1); assert_eq!(notes[0].uuid, NOTE_ID); + assert_no_status_field(&serde_json::to_value(¬es).unwrap()); let raw = app .handle(AppRequest::NoteRecord { @@ -181,6 +205,19 @@ async fn app_routes_note_list_and_append_through_services() { panic!("unexpected raw note response") }; assert_eq!(raw.content.as_deref(), Some("Body")); + assert_no_status_field(&serde_json::to_value(&raw).unwrap()); + + let detailed = app + .handle(AppRequest::NoteGet { + id: NOTE_ID.to_string(), + archived: false, + }) + .await + .unwrap(); + let AppResponse::NoteDetail(detailed) = detailed else { + panic!("unexpected note detail response") + }; + assert_no_status_field(&serde_json::to_value(&detailed).unwrap()); let appended = app .handle(AppRequest::NoteAppend { @@ -193,6 +230,7 @@ async fn app_routes_note_list_and_append_through_services() { panic!("unexpected append response") }; assert_eq!(result.note.uuid, NOTE_ID); + assert_no_status_field(&serde_json::to_value(&result).unwrap()); assert_eq!( backend.find_note_content(NOTE_ID).await.unwrap(), Some("Body\n\nMore".to_string()) diff --git a/skills/flicknote.md b/skills/flicknote.md index 781c4ee..76b3e36 100644 --- a/skills/flicknote.md +++ b/skills/flicknote.md @@ -1,112 +1,49 @@ --- name: flicknote -description: "FlickNote CLI for managing notes - add, find, detail, modify, and organize by project" +description: "MCP-first interface for daemon-backed FlickNote notes and projects" --- -# FlickNote CLI +# FlickNote MCP -Use FlickNote to save and retrieve daemon-backed, local-first notes from the command line. -Run `flicknote --help` for exact flags and examples. +Use FlickNote MCP for normal note and project operations. The MCP schemas are +the source of truth for tool names, arguments, and result fields; do not +recreate them with shell commands or Gateway requests. -## Project Use +## Identifiers -Use `--project ` when the note belongs to a known project. Follow the -user's project name if they provide one; otherwise omit `--project`. +Use the numeric short note ID returned by MCP. Do not substitute a UUID. Project +operations identify projects by their names. -## Common Commands +## Exact edits -```bash -flicknote add "note text" --project -cat note.md | flicknote add --project -flicknote upload file.pdf --project -flicknote find "keyword" -flicknote find "::topic::AI::person::瓜子" -flicknote topic list -flicknote entity list --type person -flicknote source -flicknote source 12:19 -flicknote source --info -flicknote list --project -flicknote detail -flicknote detail --tree -flicknote share -flicknote unshare -flicknote project share -flicknote project unshare -flicknote content -flicknote content --section -flicknote gateway request --path /healthz -``` +`note_modify` performs one exact, whitespace-sensitive `before`/`after` +replacement. The `before` text must occur exactly once. Include more surrounding +context when a match is ambiguous. Content-editing fields remain separate from +metadata fields, so a project or flagged-state change can be combined with an +exact edit when appropriate. -Use the numeric short ID shown by `flicknote list`. +## Section scope -## Editing Rules +Section mutation tools operate on a complete section subtree, including its +heading and child sections. Read the section tree before replacing, inserting, +renaming, or deleting a section. A section replacement must supply the complete +replacement heading and subtree; section deletion is destructive. -Use `modify` for precise edits or note metadata. Use `replace` only to replace -one complete section subtree. +## Lifecycle -```bash -cat <<'EDIT' | flicknote modify -===BEFORE=== -old text exactly as it appears -===AFTER=== -new text -EDIT +Archiving is the normal soft-delete operation. Treat archive as destructive and +use restore only when the user explicitly wants the identified archived note +back. Do not assume processing or synchronization status is part of the public +note contract. -cat section.md | flicknote replace --section -``` +## Recommended flow -`modify` requires one exact, whitespace-sensitive `===BEFORE===` / -`===AFTER===` block. The match must be unique. Add surrounding context if the -text appears more than once. +Discover with the topic/entity tools, list or find notes, read the selected note, +then apply the smallest exact or section-scoped mutation. Verify the result with +a follow-up read. The shell CLI remains for human and operational workflows; +Gateway is internal development/maintenance tooling, not the agent interface. -`replace` requires `--section`, and stdin must start with a heading. It cannot -replace a whole note or change its project/flagged state. To replace a whole -note, archive the old note and create a new one. For section IDs, run -`flicknote detail --tree`. +## More help -Mutating section commands print the updated tree after the change. - -## MCP Server - -`flicknote mcp` serves typed note, source, and project tools over local stdio -and requires the local PowerSync daemon. -Configure an MCP client to run `flicknote` with `args: ["mcp"]`. Content and -exact `before`/`after` edits are JSON fields, so MCP callers do not use shell -heredocs or edit-mode delimiters. Note tools use numeric short IDs and hide -internal UUIDs; project tools use project names. Use `note_get` for editable -content and `note_source` only for stored source data. Every data tool requires -the running sync daemon; the CLI and MCP server never open the local database -directly. - -`flicknote mcp` does not expose Gateway tools. Use the CLI command below for -authenticated Gateway access. - -## Gateway Requests - -`flicknote gateway request` makes an authenticated request only to an absolute -path on the Gateway origin configured by FlickNote. It refreshes the local -session when needed and keeps credentials inside the process. Do not extract a -JWT from `session.json`. - -```bash -flicknote gateway request --method POST --path /some-authorized-path --json '{"key":"value"}' -``` - -Use `--json` without a value to read JSON from stdin. The response body, -including SSE, is forwarded to stdout. Status and errors go to stderr. Full -URLs, redirects, caller-supplied headers, and token output are not supported. - -## More Help - -```bash -flicknote --help -flicknote add --help -flicknote upload --help -flicknote list --help -flicknote detail --help -flicknote content --help -flicknote modify --help -flicknote replace --help -flicknote project --help -``` +The installed MCP schemas define the available tools, parameters, and result +fields. Use them rather than duplicating a command reference here.