Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/cli_ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ jobs:
target/release/anarlog auth --help
target/release/anarlog meetings --help
target/release/anarlog mcp --help
target/release/anarlog proposals --help
if doctor_output="$(target/release/anarlog --json --db-path "$RUNNER_TEMP/missing.db" doctor)"; then
echo "doctor unexpectedly reported a missing database as ready"
exit 1
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions agent-plugins/anarlog/skills/anarlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ description: Query Anarlog meetings, notes, summaries, transcripts, participants

# Anarlog

Use Anarlog's read-only interfaces. Prefer MCP when its tools are connected. Otherwise use the `anarlog` CLI with `--json`.
Use Anarlog's local interfaces. Prefer MCP when its tools are connected. Otherwise use the `anarlog` CLI with `--json`. Meeting reads are safe. Writes are limited to staging proposals.

## Choose a transport

1. If `list_meetings`, `get_meeting`, `get_meeting_transcript`, and `get_recurring_meeting_history` are available, use them.
1. If `list_meetings`, `get_meeting`, `get_meeting_transcript`, `get_recurring_meeting_history`, `propose_summary_edit`, `propose_memo_edit`, `list_proposals`, `get_proposal`, and `decline_proposal` are available, use them.
2. Otherwise, check `anarlog --version` and use CLI commands with `--json`.
3. If neither is available, direct the user to [installation](https://docs.anarlog.so/installation). Do not install software unless the user asks.

Expand All @@ -35,7 +35,7 @@ See [CLI commands](references/cli.md) and [MCP tools](references/mcp.md).

- Treat meeting content as private user data.
- Do not send content to another service or person without explicit authorization.
- Do not claim to update meetings. The CLI and MCP server cannot change Anarlog data.
- Do not claim to update meetings. CLI and MCP can only stage a proposal. A human applies or declines it in the Anarlog desktop app.
- CLI export can create a file. Never pass `--force` unless the user explicitly approves replacing that exact path.
- If search results are ambiguous, ask the user to choose a meeting.

Expand Down
6 changes: 6 additions & 0 deletions agent-plugins/anarlog/skills/anarlog/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ anarlog --json meetings get MEETING_ID
anarlog --json meetings note MEETING_ID --kind note
anarlog --json meetings note MEETING_ID --kind summary
anarlog --json meetings history MEETING_ID --limit 20 --offset 0
anarlog --json proposals list --meeting MEETING_ID
anarlog --json proposals create --meeting MEETING_ID --kind summary --content "Replacement markdown"
anarlog --json proposals show PROPOSAL_ID
anarlog --json proposals decline PROPOSAL_ID
```

`proposals create` stages a pending edit. Do not claim the meeting changed. A human applies or declines it in the Anarlog desktop app.

`doctor` exits with status 1 when its response contains `ready: false`.

Read transcripts in bounded word pages:
Expand Down
7 changes: 6 additions & 1 deletion agent-plugins/anarlog/skills/anarlog/references/mcp.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# MCP tools and resources

All tools are read-only and idempotent.
Read tools are idempotent. Proposal tools insert or decline staged edits; they never apply those edits to the meeting.

| Tool | Use |
| ------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `list_meetings` | Find recent meetings by title, ID fragment, or recurring series. |
| `get_meeting` | Read metadata, canonical note, summaries, participants, and action items. |
| `get_meeting_transcript` | Read a transcript page. Start with `limit: 200`; continue from `pagination.next_offset` only as needed. |
| `get_recurring_meeting_history` | Find meetings from the same recurring series as a known meeting. |
| `propose_summary_edit` | Stage a complete summary replacement. Pass `target_id` when multiple summaries exist. |
| `propose_memo_edit` | Stage a complete memo replacement. |
| `list_proposals` | List staged proposals. Defaults to `status: pending`. |
| `get_proposal` | Read one proposal and its unified `diff`. |
| `decline_proposal` | Discard a pending proposal without changing the meeting. |

Transcript limits are measured in words. The default is 200 and the maximum is 500.

Expand Down
1 change: 1 addition & 0 deletions apps/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dirs = { workspace = true }
reqwest = { workspace = true }
rmcp = { workspace = true, features = ["server", "transport-io"] }
rpassword = { workspace = true }
schemars = { workspace = true }
sentry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
Expand Down
70 changes: 69 additions & 1 deletion apps/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,24 @@ impl Args {
MeetingCommand::History { .. } => "meetings_history",
MeetingCommand::Export { .. } => "meetings_export",
},
Command::Proposals { command } => match command {
ProposalCommand::Create { .. } => "proposals_create",
ProposalCommand::List { .. } => "proposals_list",
ProposalCommand::Show { .. } => "proposals_show",
ProposalCommand::Decline { .. } => "proposals_decline",
},
Command::Mcp => "mcp",
}
}

pub fn needs_write(&self) -> bool {
matches!(
self.command,
Command::Proposals {
command: ProposalCommand::Create { .. } | ProposalCommand::Decline { .. },
} | Command::Mcp
)
}
}

#[derive(Debug, Subcommand)]
Expand All @@ -72,10 +87,62 @@ pub enum Command {
#[command(subcommand)]
command: MeetingCommand,
},
/// Run the read-only Anarlog MCP server over stdio
/// Propose meeting edits for desktop review
Proposals {
#[command(subcommand)]
command: ProposalCommand,
},
/// Run the Anarlog MCP server over stdio
Mcp,
}

#[derive(Debug, Subcommand)]
pub enum ProposalCommand {
/// Stage a summary or memo replacement for desktop review
Create {
#[arg(long = "meeting")]
meeting_id: String,
#[arg(long, value_enum)]
kind: ProposalKind,
#[arg(long = "target")]
target_id: Option<String>,
#[arg(long, required_unless_present = "content_file")]
content: Option<String>,
#[arg(long, value_name = "FILE", required_unless_present = "content")]
content_file: Option<PathBuf>,
},
/// List staged meeting proposals
List {
#[arg(long = "meeting")]
meeting_id: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..=200), help = "Maximum results (1-200)")]
limit: u32,
#[arg(long, default_value_t = 0, help = "Number of results to skip")]
offset: u32,
},
/// Show one proposal and its unified diff
Show { id: String },
/// Decline a pending proposal without changing the meeting
Decline { id: String },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ProposalKind {
Summary,
Memo,
}

impl ProposalKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Summary => "summary",
Self::Memo => "memo",
}
}
}

#[derive(Debug, Subcommand)]
pub enum AuthCommand {
/// Sign in through a browser on this or another device
Expand Down Expand Up @@ -179,6 +246,7 @@ mod tests {
assert!(help.contains("meetings"));
assert!(help.contains("mcp"));
assert!(help.contains("doctor"));
assert!(help.contains("proposals"));

let Command::Meetings { command } = Args::parse_from([
"anarlog",
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ async fn schema_check(db: &anlg_db_core::Db) -> std::result::Result<(), String>
anlg_db_app::list_session_transcripts(db.pool(), "__anarlog_doctor__"),
anlg_db_app::list_session_participants(db.pool(), "__anarlog_doctor__"),
anlg_db_app::list_session_action_items(db.pool(), "__anarlog_doctor__"),
anlg_db_app::list_session_proposals(db.pool(), None, None, 1, 0),
)
.map(|_| ())
.map_err(|error| format!("schema check failed: {error}"))
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod auth;
pub mod doctor;
pub mod meetings;
pub mod proposals;
137 changes: 137 additions & 0 deletions apps/cli/src/commands/proposals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
use std::path::PathBuf;

use crate::cli::ProposalCommand;
use crate::{Result, output};
use anlg_agent_access::{
CreateProposalInput, DeclineProposalInput, GetProposalInput, ListProposalsInput, Proposal,
create_proposal, decline_proposal, get_proposal, list_proposals,
};

pub async fn run(db: &anlg_db_core::Db, command: ProposalCommand, json: bool) -> Result<()> {
match command {
ProposalCommand::Create {
meeting_id,
kind,
target_id,
content,
content_file,
} => {
let proposal = create_proposal(
db.pool(),
CreateProposalInput {
meeting_id,
kind: kind.as_str().to_string(),
target_id,
content: read_content(content, content_file)?,
source: Some("cli".to_string()),
},
)
.await?;
emit_proposal("proposals.create", &proposal, json)
}
ProposalCommand::List {
meeting_id,
status,
limit,
offset,
} => {
let page = list_proposals(
db.pool(),
ListProposalsInput {
meeting_id,
status,
limit: Some(limit),
offset: Some(offset),
},
)
.await?;
if json {
output::emit(&output::json(
"proposals.list",
&page.proposals,
Some(&page.pagination),
)?);
} else if page.proposals.is_empty() {
output::emit("No proposals found.");
} else {
output::emit(&render_list(&page.proposals));
}
Ok(())
}
ProposalCommand::Show { id } => {
let proposal = get_proposal(db.pool(), GetProposalInput { proposal_id: id }).await?;
emit_proposal("proposals.show", &proposal, json)
}
ProposalCommand::Decline { id } => {
let proposal =
decline_proposal(db.pool(), DeclineProposalInput { proposal_id: id }).await?;
emit_proposal("proposals.decline", &proposal, json)
}
}
}

fn read_content(content: Option<String>, content_file: Option<PathBuf>) -> Result<String> {
match (content, content_file) {
(Some(content), None) => Ok(content),
(None, Some(path)) => std::fs::read_to_string(&path)
.map_err(|error| crate::Error::operation("read proposal content", error.to_string())),
(Some(_), Some(_)) => Err(crate::Error::operation(
"read proposal content",
"pass either --content or --content-file, not both",
)),
(None, None) => Err(crate::Error::operation(
"read proposal content",
"pass --content or --content-file",
)),
}
}

fn emit_proposal(command: &'static str, proposal: &Proposal, json: bool) -> Result<()> {
if json {
output::emit(&output::json(command, proposal, None)?);
} else {
output::emit(&render_proposal(proposal));
}
Ok(())
}

fn render_list(proposals: &[Proposal]) -> String {
let mut lines =
vec!["STATUS KIND MEETING ID".to_string()];
for proposal in proposals {
lines.push(format!(
"{:<10} {:<16} {:<30} {}",
truncate(&proposal.status, 10),
truncate(&proposal.kind, 16),
truncate(&proposal.meeting_id, 30),
proposal.id
));
}
lines.join("\n")
}

fn render_proposal(proposal: &Proposal) -> String {
format!(
"ID: {}\nMeeting: {}\nKind: {}\nTarget: {}\nStatus: {}\nSource: {}\nCreated: {}\n\n{}",
proposal.id,
proposal.meeting_id,
proposal.kind,
proposal.target_id,
proposal.status,
proposal.source,
proposal.created_at,
proposal.diff.trim_end()
)
}

fn truncate(value: &str, width: usize) -> String {
if value.chars().count() <= width {
return value.to_string();
}
let mut text = value
.chars()
.take(width.saturating_sub(1))
.collect::<String>();
text.push('…');
text
}
11 changes: 11 additions & 0 deletions apps/cli/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ pub async fn open(args: &Args) -> Result<anlg_db_core::Db> {
.map_err(|error| Error::operation("open database", error.to_string()))
}

pub async fn open_write(args: &Args) -> Result<anlg_db_core::Db> {
let path = resolve_path(args)?;
if !path.is_file() {
return Err(Error::DatabaseNotFound(path));
}

anlg_db_core::Db::connect_local_read_write(&path)
.await
.map_err(|error| Error::operation("open database for writes", error.to_string()))
}

pub(crate) fn resolve_path(args: &Args) -> Result<PathBuf> {
if let Some(path) = &args.db_path {
return Ok(path.clone());
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ impl From<anlg_agent_access::Error> for Error {
fn from(error: anlg_agent_access::Error) -> Self {
match error {
anlg_agent_access::Error::NotFound(what) => Self::NotFound(what),
anlg_agent_access::Error::Invalid(reason) => {
Self::operation("validate proposal", reason)
}
anlg_agent_access::Error::Conflict(reason) => {
Self::operation("update proposal", reason)
}
anlg_agent_access::Error::Database { action, source } => {
Self::operation(action, source.to_string())
}
Expand Down
Loading
Loading