From 23a33ba79f87ae44d2aaff7b1918859c60382e06 Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Fri, 27 Mar 2026 00:41:00 +0200 Subject: [PATCH 1/6] feat(config): add PluginConfig for ChatGPT Actions metadata --- src/config.rs | 23 +++++++++++++++++++++++ src/http.rs | 1 + 2 files changed, 24 insertions(+) diff --git a/src/config.rs b/src/config.rs index cd64583..00f8538 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,15 @@ pub struct AgentsConfig { pub windsurf: bool, } +/// ChatGPT Actions plugin metadata. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PluginConfig { + pub name: Option, + pub description: Option, + pub contact_email: Option, + pub public_url: Option, +} + /// HTTP REST API configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -44,6 +53,8 @@ pub struct HttpConfig { pub rate_limit: u32, // requests per minute per key, 0 = unlimited pub cors_origins: Vec, pub api_keys: Vec, + #[serde(default)] + pub plugin: PluginConfig, } impl Default for HttpConfig { @@ -55,6 +66,7 @@ impl Default for HttpConfig { rate_limit: 60, cors_origins: vec![], api_keys: vec![], + plugin: PluginConfig::default(), } } } @@ -356,4 +368,15 @@ permissions = "read" Some("hf:custom/model/embed.gguf".into()) ); } + + #[test] + fn test_config_with_plugin() { + let toml = r#" +[http.plugin] +name = "my-vault" +public_url = "https://vault.example.com" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.http.plugin.name.as_deref(), Some("my-vault")); + } } diff --git a/src/http.rs b/src/http.rs index b92bd04..eda33ee 100644 --- a/src/http.rs +++ b/src/http.rs @@ -941,6 +941,7 @@ mod tests { permissions: "write".into(), }, ], + plugin: crate::config::PluginConfig::default(), } } From ffac6738907fa758ade39a86ded2931726253d87 Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Fri, 27 Mar 2026 00:43:59 +0200 Subject: [PATCH 2/6] feat(openapi): add OpenAPI 3.1.0 spec and ChatGPT plugin manifest builders --- src/lib.rs | 1 + src/openapi.rs | 567 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+) create mode 100644 src/openapi.rs diff --git a/src/lib.rs b/src/lib.rs index f2a24b0..779e26e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod llm; pub mod markdown; pub mod migrate; pub mod obsidian; +pub mod openapi; pub mod placement; pub mod profile; pub mod search; diff --git a/src/openapi.rs b/src/openapi.rs new file mode 100644 index 0000000..0039c12 --- /dev/null +++ b/src/openapi.rs @@ -0,0 +1,567 @@ +use crate::config::HttpConfig; + +/// Build the OpenAPI 3.1.0 specification for all HTTP endpoints. +pub fn build_openapi_spec(server_url: &str) -> serde_json::Value { + let mut paths = serde_json::Map::new(); + + // Read endpoints + paths.insert("/api/health-check".into(), build_health_check()); + paths.insert("/api/search".into(), build_search()); + paths.insert("/api/read/{file}".into(), build_read()); + paths.insert("/api/read-section".into(), build_read_section()); + paths.insert("/api/list".into(), build_list()); + paths.insert("/api/vault-map".into(), build_vault_map()); + paths.insert("/api/who/{name}".into(), build_who()); + paths.insert("/api/project/{name}".into(), build_project()); + paths.insert("/api/context".into(), build_context()); + paths.insert("/api/health".into(), build_health()); + + // Write endpoints + paths.insert("/api/create".into(), build_create()); + paths.insert("/api/append".into(), build_append()); + paths.insert("/api/edit".into(), build_edit()); + paths.insert("/api/rewrite".into(), build_rewrite()); + paths.insert("/api/edit-frontmatter".into(), build_edit_frontmatter()); + paths.insert("/api/move".into(), build_move()); + paths.insert("/api/archive".into(), build_archive()); + paths.insert("/api/unarchive".into(), build_unarchive()); + paths.insert("/api/update-metadata".into(), build_update_metadata()); + paths.insert("/api/delete".into(), build_delete()); + + // Migration endpoints + paths.insert("/api/migrate/preview".into(), build_migrate_preview()); + paths.insert("/api/migrate/apply".into(), build_migrate_apply()); + paths.insert("/api/migrate/undo".into(), build_migrate_undo()); + + serde_json::json!({ + "openapi": "3.1.0", + "info": { + "title": "engraph", + "version": "1.5.0", + "description": "AI-powered semantic search and management API for Obsidian vaults." + }, + "servers": [{ "url": server_url }], + "security": [{ "bearerAuth": [] }], + "components": { + "securitySchemes": { + "bearerAuth": { "type": "http", "scheme": "bearer" } + } + }, + "paths": paths + }) +} + +// --------------------------------------------------------------------------- +// Path builders — each returns one path item to keep macro recursion shallow +// --------------------------------------------------------------------------- + +fn build_health_check() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "healthCheck", + "summary": "Simple liveness check. Returns 'ok' when the server is running.", + "responses": { + "200": { "description": "Server is alive" } + } + } + }) +} + +fn build_search() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "searchVault", + "summary": "Hybrid semantic + full-text search across the vault.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string", "description": "Search query text" }, + "top_n": { "type": "integer", "description": "Number of results (default 10)" } + } + }}} + }, + "responses": { "200": { "description": "Search results with scores and snippets" } } + } + }) +} + +fn build_read() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "readNote", + "summary": "Read a note's full content with metadata and graph connections.", + "parameters": [{ + "name": "file", "in": "path", "required": true, + "description": "File path, basename, or #docid", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "Note content with metadata" } } + } + }) +} + +fn build_read_section() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "readSection", + "summary": "Read a specific section of a note by heading name.", + "parameters": [ + { "name": "file", "in": "query", "required": true, "description": "File path, basename, or #docid", "schema": { "type": "string" } }, + { "name": "heading", "in": "query", "required": true, "description": "Section heading (case-insensitive)", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Section content" } } + } + }) +} + +fn build_list() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "listNotes", + "summary": "List notes filtered by folder, tags, creator, or limit.", + "parameters": [ + { "name": "folder", "in": "query", "required": false, "description": "Folder path prefix filter", "schema": { "type": "string" } }, + { "name": "tags", "in": "query", "required": false, "description": "Comma-separated tags", "schema": { "type": "string" } }, + { "name": "created_by", "in": "query", "required": false, "description": "Agent filter", "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "required": false, "description": "Max results (default 20)", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Array of note summaries" } } + } + }) +} + +fn build_vault_map() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "getVaultMap", + "summary": "Get vault structure overview with folder tree, tag cloud, and statistics.", + "responses": { "200": { "description": "Vault structure map" } } + } + }) +} + +fn build_who() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "getWho", + "summary": "Get a person context bundle with note, related notes, and interaction history.", + "parameters": [{ + "name": "name", "in": "path", "required": true, + "description": "Person name (matches filename in People folder)", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "Person context bundle" } } + } + }) +} + +fn build_project() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "getProject", + "summary": "Get a project context bundle with project note, related files, and graph connections.", + "parameters": [{ + "name": "name", "in": "path", "required": true, + "description": "Project name (matches filename)", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "Project context bundle" } } + } + }) +} + +fn build_context() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "getContext", + "summary": "Get rich topic context with semantic search, graph expansion, and budget-aware trimming.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["topic"], + "properties": { + "topic": { "type": "string", "description": "Topic or question" }, + "budget": { "type": "integer", "description": "Character budget (default 32000)" } + } + }}} + }, + "responses": { "200": { "description": "Context bundle with notes and metadata" } } + } + }) +} + +fn build_health() -> serde_json::Value { + serde_json::json!({ + "get": { + "operationId": "getHealth", + "summary": "Get vault health report with orphans, broken links, stale notes, and inbox status.", + "responses": { "200": { "description": "Vault health report" } } + } + }) +} + +fn build_create() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "createNote", + "summary": "Create a new note with automatic placement and frontmatter generation.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["content"], + "properties": { + "content": { "type": "string", "description": "Note content (markdown)" }, + "filename": { "type": "string", "description": "Filename without .md" }, + "type_hint": { "type": "string", "description": "Type hint for placement" }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to apply" }, + "folder": { "type": "string", "description": "Explicit folder (skips auto-placement)" } + } + }}} + }, + "responses": { "200": { "description": "Created note path and metadata" } } + } + }) +} + +fn build_append() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "appendToNote", + "summary": "Append content to the end of an existing note.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file", "content"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "content": { "type": "string", "description": "Content to append" } + } + }}} + }, + "responses": { "200": { "description": "Updated note path and metadata" } } + } + }) +} + +fn build_edit() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "editNote", + "summary": "Edit a specific section of a note by heading. Supports replace, prepend, append modes.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file", "heading", "content"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "heading": { "type": "string", "description": "Section heading (case-insensitive)" }, + "content": { "type": "string", "description": "Content to add or replace" }, + "mode": { "type": "string", "description": "'replace', 'prepend', or 'append' (default)" } + } + }}} + }, + "responses": { "200": { "description": "Updated note path and metadata" } } + } + }) +} + +fn build_rewrite() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "rewriteNote", + "summary": "Rewrite a note's body content. Preserves frontmatter by default.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file", "content"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "content": { "type": "string", "description": "New body content" }, + "preserve_frontmatter": { "type": "boolean", "description": "Preserve frontmatter (default true)" } + } + }}} + }, + "responses": { "200": { "description": "Updated note path and metadata" } } + } + }) +} + +fn build_edit_frontmatter() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "editFrontmatter", + "summary": "Edit a note's frontmatter with structured operations (set, remove, add_tag, etc.).", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file", "operations"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "operations": { + "type": "array", + "description": "Frontmatter operations", + "items": { "type": "object", "properties": { + "op": { "type": "string", "description": "set/remove/add_tag/remove_tag/add_alias/remove_alias" }, + "key": { "type": "string", "description": "Property key (for set/remove)" }, + "value": { "type": "string", "description": "Value" } + }} + } + } + }}} + }, + "responses": { "200": { "description": "Updated note path and metadata" } } + } + }) +} + +fn build_move() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "moveNote", + "summary": "Move a note to a different folder within the vault.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file", "new_folder"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "new_folder": { "type": "string", "description": "Destination folder path" } + } + }}} + }, + "responses": { "200": { "description": "New note path" } } + } + }) +} + +fn build_archive() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "archiveNote", + "summary": "Archive a note (soft delete). Moves to archive folder and removes from index.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" } + } + }}} + }, + "responses": { "200": { "description": "Archived note path" } } + } + }) +} + +fn build_unarchive() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "unarchiveNote", + "summary": "Restore an archived note to its original location and re-index it.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { "type": "string", "description": "Archived note path" } + } + }}} + }, + "responses": { "200": { "description": "Restored note path" } } + } + }) +} + +fn build_update_metadata() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "updateMetadata", + "summary": "Update a note's tags and aliases in bulk.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "New tags (replaces existing)" }, + "aliases": { "type": "array", "items": { "type": "string" }, "description": "New aliases (replaces existing)" } + } + }}} + }, + "responses": { "200": { "description": "Updated note metadata" } } + } + }) +} + +fn build_delete() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "deleteNote", + "summary": "Delete a note. Supports soft (archive) and hard (permanent) modes.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { "type": "string", "description": "Target note (path, basename, or #docid)" }, + "mode": { "type": "string", "description": "'soft' (default) or 'hard'" } + } + }}} + }, + "responses": { "200": { "description": "Deletion confirmation" } } + } + }) +} + +fn build_migrate_preview() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "migratePreview", + "summary": "Generate a PARA migration preview. Classifies notes and suggests folder moves.", + "requestBody": { + "content": { "application/json": { "schema": { "type": "object", "properties": {} } } } + }, + "responses": { "200": { "description": "Migration preview with proposed moves" } } + } + }) +} + +fn build_migrate_apply() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "migrateApply", + "summary": "Apply a previously generated migration preview. Moves notes to suggested PARA folders.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["preview"], + "properties": { + "preview": { "type": "object", "description": "Migration preview from migratePreview" } + } + }}} + }, + "responses": { "200": { "description": "Migration result with moved files count" } } + } + }) +} + +fn build_migrate_undo() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "migrateUndo", + "summary": "Undo the last applied migration, restoring notes to original locations.", + "requestBody": { + "content": { "application/json": { "schema": { "type": "object", "properties": {} } } } + }, + "responses": { "200": { "description": "Undo result with restored files count" } } + } + }) +} + +/// Build the ChatGPT plugin manifest (ai-plugin.json). +pub fn build_plugin_manifest(config: &HttpConfig, server_url: &str) -> serde_json::Value { + serde_json::json!({ + "schema_version": "v1", + "name_for_human": config.plugin.name.as_deref().unwrap_or("engraph"), + "name_for_model": "engraph", + "description_for_human": config.plugin.description.as_deref() + .unwrap_or("Search and manage your Obsidian vault with AI-powered hybrid search."), + "description_for_model": "Access an Obsidian knowledge vault. Use search to find notes by content or time. read for full content. who/project for context bundles. Write tools create, edit, and organize notes.", + "auth": { + "type": "service_http", + "authorization_type": "bearer", + "verification_tokens": {} + }, + "api": { + "type": "openapi", + "url": format!("{}/openapi.json", server_url) + }, + "logo_url": "", + "contact_email": config.plugin.contact_email.as_deref().unwrap_or(""), + "legal_info_url": "" + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_openapi_spec_structure() { + let spec = build_openapi_spec("http://localhost:3000"); + assert_eq!(spec["openapi"], "3.1.0"); + assert!(spec["paths"].as_object().unwrap().len() >= 20); + assert_eq!(spec["servers"][0]["url"], "http://localhost:3000"); + } + + #[test] + fn test_openapi_has_security() { + let spec = build_openapi_spec("http://localhost:3000"); + assert!(spec["components"]["securitySchemes"]["bearerAuth"].is_object()); + } + + #[test] + fn test_plugin_manifest() { + let config = crate::config::HttpConfig::default(); + let manifest = build_plugin_manifest(&config, "https://vault.example.com"); + assert_eq!(manifest["schema_version"], "v1"); + assert_eq!(manifest["name_for_model"], "engraph"); + assert!(manifest["api"]["url"].as_str().unwrap().contains("openapi.json")); + } + + #[test] + fn test_openapi_has_all_operation_ids() { + let spec = build_openapi_spec("http://localhost:3000"); + let paths = spec["paths"].as_object().unwrap(); + let mut op_ids: Vec = Vec::new(); + for (_path, methods) in paths { + for (_method, details) in methods.as_object().unwrap() { + if let Some(id) = details.get("operationId").and_then(|v| v.as_str()) { + op_ids.push(id.to_string()); + } + } + } + let expected = vec![ + "healthCheck", "searchVault", "readNote", "readSection", + "listNotes", "getVaultMap", "getWho", "getProject", + "getContext", "getHealth", "createNote", "appendToNote", + "editNote", "rewriteNote", "editFrontmatter", "moveNote", + "archiveNote", "unarchiveNote", "updateMetadata", "deleteNote", + "migratePreview", "migrateApply", "migrateUndo", + ]; + for id in &expected { + assert!(op_ids.contains(&id.to_string()), "Missing operationId: {id}"); + } + } + + #[test] + fn test_openapi_server_url_passed_through() { + let spec = build_openapi_spec("https://my-tunnel.example.com"); + assert_eq!(spec["servers"][0]["url"], "https://my-tunnel.example.com"); + } + + #[test] + fn test_plugin_manifest_custom_config() { + let mut config = crate::config::HttpConfig::default(); + config.plugin.name = Some("my-vault".into()); + config.plugin.contact_email = Some("test@example.com".into()); + let manifest = build_plugin_manifest(&config, "https://example.com"); + assert_eq!(manifest["name_for_human"], "my-vault"); + assert_eq!(manifest["contact_email"], "test@example.com"); + } +} From 6246ed95a3624e2c051fe3359c0fd6c018e09146 Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Fri, 27 Mar 2026 00:44:53 +0200 Subject: [PATCH 3/6] feat(http): serve OpenAPI spec and ChatGPT plugin manifest (no auth required) --- src/http.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/http.rs b/src/http.rs index eda33ee..a704dbf 100644 --- a/src/http.rs +++ b/src/http.rs @@ -380,6 +380,9 @@ pub fn build_router(state: ApiState) -> Router { .route("/api/migrate/preview", post(handle_migrate_preview)) .route("/api/migrate/apply", post(handle_migrate_apply)) .route("/api/migrate/undo", post(handle_migrate_undo)) + // OpenAPI / ChatGPT plugin discovery (no auth required) + .route("/openapi.json", get(handle_openapi)) + .route("/.well-known/ai-plugin.json", get(handle_plugin_manifest)) .layer(cors) .with_state(state) } @@ -388,6 +391,30 @@ async fn health_check() -> &'static str { "ok" } +async fn handle_openapi(State(state): State) -> impl IntoResponse { + let default_url = format!("http://{}:{}", state.http_config.host, state.http_config.port); + let server_url = state + .http_config + .plugin + .public_url + .as_deref() + .unwrap_or(&default_url); + let spec = crate::openapi::build_openapi_spec(server_url); + Json(spec) +} + +async fn handle_plugin_manifest(State(state): State) -> impl IntoResponse { + let default_url = format!("http://{}:{}", state.http_config.host, state.http_config.port); + let server_url = state + .http_config + .plugin + .public_url + .as_deref() + .unwrap_or(&default_url); + let manifest = crate::openapi::build_plugin_manifest(&state.http_config, server_url); + Json(manifest) +} + // --------------------------------------------------------------------------- // Read endpoint handlers // --------------------------------------------------------------------------- @@ -1273,4 +1300,40 @@ mod tests { assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); assert!(response.headers().get("retry-after").is_some()); } + + // ----------------------------------------------------------------------- + // OpenAPI / Plugin manifest tests (no auth required) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_openapi_no_auth_required() { + let state = test_api_state(); + let app = build_router(state); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/openapi.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_plugin_manifest_no_auth_required() { + let state = test_api_state(); + let app = build_router(state); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/.well-known/ai-plugin.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } } From 1c0065d1ecf6a85b6ac2d081bacdaf9fa6538050 Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Fri, 27 Mar 2026 00:46:50 +0200 Subject: [PATCH 4/6] feat(cli): add --setup-chatgpt interactive helper --- src/main.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/main.rs b/src/main.rs index 89f513d..d5c497a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -117,6 +117,10 @@ enum Command { /// Revoke an API key by name. #[arg(long)] revoke_api_key: Option, + + /// Interactive setup for ChatGPT Actions integration. + #[arg(long)] + setup_chatgpt: bool, }, /// Manage embedding models. @@ -675,6 +679,7 @@ async fn main() -> Result<()> { key_permissions, list_api_keys, revoke_api_key, + setup_chatgpt, } => { let mut cfg = Config::load()?; @@ -816,6 +821,64 @@ async fn main() -> Result<()> { } } + if setup_chatgpt { + println!("Setting up engraph for ChatGPT Actions...\n"); + + if !cfg.http.enabled { + cfg.http.enabled = true; + println!("\u{2713} HTTP server enabled"); + } else { + println!("\u{2713} HTTP server already enabled"); + } + + if cfg.http.api_keys.is_empty() { + let key = engraph::http::generate_api_key(); + cfg.http.api_keys.push(engraph::config::ApiKeyConfig { + key: key.clone(), + name: "chatgpt".into(), + permissions: "read".into(), + }); + println!("\u{2713} API key created: {key}"); + println!(" Save this \u{2014} you'll need it for ChatGPT Action setup."); + } else { + println!("\u{2713} API key already configured"); + } + + let chatgpt_origin = "https://chat.openai.com".to_string(); + if !cfg.http.cors_origins.contains(&chatgpt_origin) { + cfg.http.cors_origins.push(chatgpt_origin); + println!("\u{2713} CORS origin added: https://chat.openai.com"); + } else { + println!("\u{2713} CORS already configured for ChatGPT"); + } + + eprint!("\nPublic URL (leave empty to skip): "); + io::stderr().flush().ok(); + let mut url = String::new(); + io::stdin().lock().read_line(&mut url).ok(); + let url = url.trim(); + if !url.is_empty() { + cfg.http.plugin.public_url = Some(url.to_string()); + println!("\u{2713} Public URL: {url}"); + } + + cfg.save()?; + println!("\nSetup complete. Next steps:"); + println!("1. engraph serve --http"); + println!( + "2. Expose via tunnel: cloudflared tunnel --url http://localhost:{}", + cfg.http.port + ); + if !url.is_empty() { + println!( + "3. ChatGPT \u{2192} Create GPT \u{2192} Add Action \u{2192} Import from: {url}/openapi.json" + ); + } else { + println!("3. ChatGPT \u{2192} Create GPT \u{2192} Add Action \u{2192} Import from: /openapi.json"); + } + println!("4. Auth: API Key, Bearer, paste your key"); + } + cfg.save()?; println!( "Configuration saved to {}", From b4cf6c036317171df221d51381de2da69c3ecc8a Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Fri, 27 Mar 2026 00:48:16 +0200 Subject: [PATCH 5/6] fix: apply cargo fmt for v1.5 --- src/http.rs | 10 ++++++++-- src/main.rs | 4 +++- src/openapi.rs | 41 +++++++++++++++++++++++++++++++++-------- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/http.rs b/src/http.rs index a704dbf..0410d5a 100644 --- a/src/http.rs +++ b/src/http.rs @@ -392,7 +392,10 @@ async fn health_check() -> &'static str { } async fn handle_openapi(State(state): State) -> impl IntoResponse { - let default_url = format!("http://{}:{}", state.http_config.host, state.http_config.port); + let default_url = format!( + "http://{}:{}", + state.http_config.host, state.http_config.port + ); let server_url = state .http_config .plugin @@ -404,7 +407,10 @@ async fn handle_openapi(State(state): State) -> impl IntoResponse { } async fn handle_plugin_manifest(State(state): State) -> impl IntoResponse { - let default_url = format!("http://{}:{}", state.http_config.host, state.http_config.port); + let default_url = format!( + "http://{}:{}", + state.http_config.host, state.http_config.port + ); let server_url = state .http_config .plugin diff --git a/src/main.rs b/src/main.rs index d5c497a..82f564f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -874,7 +874,9 @@ async fn main() -> Result<()> { "3. ChatGPT \u{2192} Create GPT \u{2192} Add Action \u{2192} Import from: {url}/openapi.json" ); } else { - println!("3. ChatGPT \u{2192} Create GPT \u{2192} Add Action \u{2192} Import from: /openapi.json"); + println!( + "3. ChatGPT \u{2192} Create GPT \u{2192} Add Action \u{2192} Import from: /openapi.json" + ); } println!("4. Auth: API Key, Bearer, paste your key"); } diff --git a/src/openapi.rs b/src/openapi.rs index 0039c12..7e6a9b4 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -521,7 +521,12 @@ mod tests { let manifest = build_plugin_manifest(&config, "https://vault.example.com"); assert_eq!(manifest["schema_version"], "v1"); assert_eq!(manifest["name_for_model"], "engraph"); - assert!(manifest["api"]["url"].as_str().unwrap().contains("openapi.json")); + assert!( + manifest["api"]["url"] + .as_str() + .unwrap() + .contains("openapi.json") + ); } #[test] @@ -537,15 +542,35 @@ mod tests { } } let expected = vec![ - "healthCheck", "searchVault", "readNote", "readSection", - "listNotes", "getVaultMap", "getWho", "getProject", - "getContext", "getHealth", "createNote", "appendToNote", - "editNote", "rewriteNote", "editFrontmatter", "moveNote", - "archiveNote", "unarchiveNote", "updateMetadata", "deleteNote", - "migratePreview", "migrateApply", "migrateUndo", + "healthCheck", + "searchVault", + "readNote", + "readSection", + "listNotes", + "getVaultMap", + "getWho", + "getProject", + "getContext", + "getHealth", + "createNote", + "appendToNote", + "editNote", + "rewriteNote", + "editFrontmatter", + "moveNote", + "archiveNote", + "unarchiveNote", + "updateMetadata", + "deleteNote", + "migratePreview", + "migrateApply", + "migrateUndo", ]; for id in &expected { - assert!(op_ids.contains(&id.to_string()), "Missing operationId: {id}"); + assert!( + op_ids.contains(&id.to_string()), + "Missing operationId: {id}" + ); } } From 7d5923bdb45fc12be76dcc0f34e90a517ce4f7c6 Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Fri, 27 Mar 2026 00:49:51 +0200 Subject: [PATCH 6/6] docs: update CLAUDE.md, README, CHANGELOG for v1.5 --- CHANGELOG.md | 13 ++++++++++++ CLAUDE.md | 9 ++++---- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d39c8e2..fb3df2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v1.5.0 — ChatGPT Actions (2026-03-26) + +### Added +- **OpenAPI 3.1.0 spec** (`openapi.rs`) — hand-written spec for all 23 endpoints, served at `GET /openapi.json` +- **ChatGPT plugin manifest** — served at `GET /.well-known/ai-plugin.json` +- **`--setup-chatgpt` CLI helper** — interactive setup: enables HTTP, creates API key, configures CORS, prompts for public URL +- **Plugin config** — `[http.plugin]` section for name, description, contact_email, public_url + +### Changed +- Module count: 25 → 26 +- Test count: 417 → 426 +- `/openapi.json` and `/.well-known/ai-plugin.json` routes require no authentication + ## v1.4.0 — PARA Migration (2026-03-26) ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 31e96b6..d7d9669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Local knowledge graph + intelligence layer for Obsidian vaults. Rust CLI + MCP s ## Architecture -Single binary with 25 modules behind a lib crate: +Single binary with 26 modules behind a lib crate: - `config.rs` — loads `~/.engraph/config.toml` and `vault.toml`, merges CLI args, provides `data_dir()`. Includes `intelligence: Option`, `[models]` section for model overrides, `[obsidian]` section (CLI path, enabled flag), and `[agents]` section (registered AI agent names). `Config::save()` writes back to disk. - `chunker.rs` — smart chunking with break-point scoring algorithm. Finds optimal split points considering headings, code fences, blank lines, and thematic breaks. `split_oversized_chunks()` handles token-aware secondary splitting with overlap @@ -23,8 +23,9 @@ Single binary with 25 modules behind a lib crate: - `placement.rs` — folder placement engine. Uses folder centroids (online mean of embeddings per folder) to suggest the best folder for new notes. Falls back to inbox when confidence is low. Includes placement correction detection (`detect_correction_from_frontmatter`) and frontmatter stripping for moved files - `writer.rs` — write pipeline orchestrator. 5-step pipeline: resolve tags (fuzzy match + register new), discover links (exact + fuzzy), place in folder, atomic file write (temp + rename), and index update. Supports create, append, update_metadata, move_note, archive, unarchive, edit (section-level replace/prepend/append), rewrite (full content with frontmatter preservation), edit_frontmatter (granular set/remove/add_tag/remove_tag/add_alias/remove_alias ops), and delete (soft archive or hard permanent) operations with mtime-based conflict detection and crash recovery via temp file cleanup - `watcher.rs` — file watcher for `engraph serve`. OS thread producer (notify-debouncer-full, 2s debounce) sends `Vec` over tokio::mpsc to async consumer task. Two-pass batch processing: mutations (index_file/remove_file/rename_file) then edge rebuild. Move detection via content hash matching. Placement correction on file moves. Centroid adjustment on file add/remove. Startup reconciliation via `run_index_shared`. `recent_writes` map coordination with MCP server to prevent double re-indexing of files written through the write pipeline -- `serve.rs` — MCP stdio server via rmcp SDK. Exposes 22 tools: 8 read (search, read, read_section, list, vault_map, who, project, context) + 10 write (create, append, update_metadata, move_note, archive, unarchive, edit, rewrite, edit_frontmatter, delete) + 1 diagnostic (health) + 3 migrate (migrate_preview, migrate_apply, migrate_undo). `edit_frontmatter` replaces `update_metadata` for granular frontmatter mutations. EngraphServer struct with Arc+Mutex wrapping for async handlers. Loads intelligence models (orchestrator + reranker) when enabled, wires into `search_with_intelligence`. Spawns file watcher on startup. CLI events table provides audit log for write operations. `recent_writes` map prevents double re-indexing of MCP-written files +- `serve.rs` — MCP stdio server via rmcp SDK. Exposes 22 tools: 8 read (search, read, read_section, list, vault_map, who, project, context) + 10 write (create, append, update_metadata, move_note, archive, unarchive, edit, rewrite, edit_frontmatter, delete) + 1 diagnostic (health) + 3 migrate (migrate_preview, migrate_apply, migrate_undo). `edit_frontmatter` replaces `update_metadata` for granular frontmatter mutations. EngraphServer struct with Arc+Mutex wrapping for async handlers. Loads intelligence models (orchestrator + reranker) when enabled, wires into `search_with_intelligence`. Spawns file watcher on startup. CLI events table provides audit log for write operations. `recent_writes` map prevents double re-indexing of MCP-written files. HTTP mode also serves `openapi.rs` routes (`/openapi.json`, `/.well-known/ai-plugin.json`) with no auth required - `http.rs` — axum-based HTTP REST API server, enabled via `engraph serve --http`. 23 REST endpoints mirroring all 22 MCP tools + update-metadata. API key authentication with `eg_` prefixed keys and read/write permission levels. Per-key token bucket rate limiting (configurable requests/minute). CORS with configurable allowed origins for web-based agents. `--no-auth` mode for local development (127.0.0.1 only). Graceful shutdown via `CancellationToken` coordinating MCP + HTTP + watcher exit +- `openapi.rs` — OpenAPI 3.1.0 spec builder and ChatGPT plugin manifest. Hand-written spec for all 23 HTTP endpoints, served at `GET /openapi.json`. Plugin manifest served at `GET /.well-known/ai-plugin.json`. Both routes require no authentication. `[http.plugin]` config section for name, description, contact_email, and public_url. Used by `engraph configure --setup-chatgpt` for interactive ChatGPT Actions setup - `graph.rs` — vault graph agent. Extracts wikilink targets, expands search results by following graph connections 1-2 hops. Relevance filtering via FTS5 term check and shared tags - `profile.rs` — vault profile detection. Auto-detects PARA/Folders/Flat structure, vault type (Obsidian/Logseq/Plain), wikilinks, frontmatter, tags. Content-based role detection for people/daily/archive folders by content patterns (not just names). Writes/loads `vault.toml` - `store.rs` — SQLite persistence. Tables: `meta`, `files` (with docid, created_by), `chunks` (with vector BLOBs), `chunks_fts` (FTS5), `edges` (vault graph), `tombstones`, `tag_registry`, `folder_centroids`, `placement_corrections`, `link_skiplist` (reserved), `llm_cache` (orchestrator result cache), `cli_events` (audit log for CLI operations). `vec_chunks` virtual table (sqlite-vec) for KNN search. Dynamic embedding dimension stored in meta. `has_dimension_mismatch()` and `reset_for_reindex()` for migration. Enhanced `resolve_file()` with fuzzy Levenshtein matching as final fallback @@ -32,7 +33,7 @@ Single binary with 25 modules behind a lib crate: - `temporal.rs` — temporal search lane. Extracts note dates from frontmatter `date:` field or `YYYY-MM-DD` filename patterns. Heuristic date parsing for natural language ("today", "yesterday", "last week", "this month", "recent", month names, ISO dates, date ranges). Smooth decay scoring for files near but outside target date range. Provides `extract_note_date()` for indexing and `score_temporal()` + `parse_date_range_heuristic()` for search - `search.rs` — hybrid search orchestrator. `search_with_intelligence()` runs the full pipeline: orchestrate (intent + expansions) → 5-lane RRF retrieval (semantic + FTS5 + graph + reranker + temporal) per expansion → two-pass RRF fusion. `search_internal()` is a thin wrapper without intelligence models. Adaptive lane weights per query intent including temporal (1.5 weight for time-aware queries). Results display normalized confidence percentages (0-100%) instead of raw RRF scores. -`main.rs` is a thin clap CLI (async via `#[tokio::main]`). Subcommands: `index` (with progress bar), `search` (with `--explain`, loads intelligence models when enabled), `status` (shows intelligence state + date coverage stats), `clear`, `init` (intelligence onboarding prompt, detects Obsidian CLI + AI agents), `configure` (`--enable-intelligence`, `--disable-intelligence`, `--model`, `--obsidian-cli`, `--no-obsidian-cli`, `--agent`, `--add-api-key`, `--list-api-keys`, `--revoke-api-key`), `models`, `graph` (show/stats), `context` (read/list/vault-map/who/project/topic), `write` (create/append/update-metadata/move/edit/rewrite/edit-frontmatter/delete), `migrate` (para with `--preview`/`--apply`/`--undo` for PARA vault restructuring), `serve` (MCP stdio server with file watcher + intelligence + optional `--http`/`--port`/`--host`/`--no-auth` for HTTP REST API). +`main.rs` is a thin clap CLI (async via `#[tokio::main]`). Subcommands: `index` (with progress bar), `search` (with `--explain`, loads intelligence models when enabled), `status` (shows intelligence state + date coverage stats), `clear`, `init` (intelligence onboarding prompt, detects Obsidian CLI + AI agents), `configure` (`--enable-intelligence`, `--disable-intelligence`, `--model`, `--obsidian-cli`, `--no-obsidian-cli`, `--agent`, `--add-api-key`, `--list-api-keys`, `--revoke-api-key`, `--setup-chatgpt`), `models`, `graph` (show/stats), `context` (read/list/vault-map/who/project/topic), `write` (create/append/update-metadata/move/edit/rewrite/edit-frontmatter/delete), `migrate` (para with `--preview`/`--apply`/`--undo` for PARA vault restructuring), `serve` (MCP stdio server with file watcher + intelligence + optional `--http`/`--port`/`--host`/`--no-auth` for HTTP REST API). ## Key patterns @@ -81,7 +82,7 @@ Single vault only. Re-indexing a different vault path triggers a confirmation pr ## Testing -- Unit tests in each module (`cargo test --lib`) — 417 tests, no network required +- Unit tests in each module (`cargo test --lib`) — 426 tests, no network required - Integration tests (`cargo test --test integration -- --ignored`) — require GGUF model download - Build requires CMake (for llama.cpp C++ compilation) diff --git a/README.md b/README.md index 658277c..201eab4 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,55 @@ Notes that don't match any signal with sufficient confidence stay in place. Dail **HTTP endpoints:** `POST /api/migrate/preview`, `/api/migrate/apply`, `/api/migrate/undo` — available via `engraph serve --http`. +## ChatGPT Actions + +`engraph serve --http` can expose your vault to ChatGPT as a custom Action. engraph generates a standards-compliant OpenAPI 3.1.0 spec and a ChatGPT plugin manifest automatically. + +**Set up:** + +```bash +engraph configure --setup-chatgpt +``` + +Interactive helper that: +1. Enables HTTP mode (if not already on) +2. Creates a write-permission API key +3. Configures CORS for `https://chat.openai.com` +4. Prompts for your public URL (ngrok or similar) + +**Endpoints served automatically (no auth required):** + +| Endpoint | Description | +|----------|-------------| +| `GET /openapi.json` | OpenAPI 3.1.0 spec for all 23 endpoints | +| `GET /.well-known/ai-plugin.json` | ChatGPT plugin manifest | + +**Quick setup steps:** + +```bash +# 1. Run the setup helper +engraph configure --setup-chatgpt + +# 2. Start engraph with HTTP +engraph serve --http + +# 3. Expose via tunnel (example with ngrok) +ngrok http 3030 + +# 4. In ChatGPT → Explore GPTs → Create → Configure → Add Action +# Import from URL: https:///openapi.json +``` + +**Plugin config in `~/.engraph/config.toml`:** + +```toml +[http.plugin] +name = "My Vault" +description = "Search and read my personal knowledge base" +contact_email = "you@example.com" +public_url = "https://abc123.ngrok.io" +``` + ## Use cases **AI-assisted knowledge work** — Give Claude or Cursor deep access to your personal knowledge base. Instead of copy-pasting context, the agent searches, reads, and cross-references your notes directly. @@ -425,7 +474,7 @@ engraph is not a replacement for Obsidian — it's the intelligence layer that s - Content-based folder role detection (people, daily, archive) by content patterns - PARA migration: AI-assisted vault restructuring into Projects/Areas/Resources/Archive with preview, apply, and undo workflow - Configurable model overrides for multilingual support -- 417 unit tests, CI on macOS + Ubuntu +- 426 unit tests, CI on macOS + Ubuntu ## Roadmap @@ -437,7 +486,8 @@ engraph is not a replacement for Obsidian — it's the intelligence layer that s - [x] ~~Temporal search — find notes by time period, date-aware queries~~ (v1.2) - [x] ~~HTTP/REST API — complement MCP with a standard web API~~ (v1.3) - [x] ~~PARA migration — AI-assisted vault restructuring with preview/apply/undo~~ (v1.4) -- [ ] Multi-vault — search across multiple vaults (v1.5) +- [x] ~~ChatGPT Actions — OpenAPI 3.1.0 spec + plugin manifest + `--setup-chatgpt` helper~~ (v1.5) +- [ ] Multi-vault — search across multiple vaults (v1.6) ## Configuration @@ -471,7 +521,7 @@ All data stored in `~/.engraph/` — single SQLite database (~10MB typical), GGU ## Development ```bash -cargo test --lib # 417 unit tests, no network (requires CMake for llama.cpp) +cargo test --lib # 426 unit tests, no network (requires CMake for llama.cpp) cargo clippy -- -D warnings cargo fmt --check @@ -483,7 +533,7 @@ cargo test --test integration -- --ignored Contributions welcome. Please open an issue first to discuss what you'd like to change. -The codebase is 25 Rust modules behind a lib crate. `CLAUDE.md` in the repo root has detailed architecture documentation for AI-assisted development. +The codebase is 26 Rust modules behind a lib crate. `CLAUDE.md` in the repo root has detailed architecture documentation for AI-assisted development. ## License