Story 37: Editor Command for Worktrees

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 14:42:41 +00:00
parent 53d9795e31
commit ed5f34b776
5 changed files with 460 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
use crate::config::ProjectConfig;
use crate::http::context::AppContext;
use crate::http::settings::get_editor_command_from_store;
use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs};
use crate::worktree;
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
@@ -562,6 +563,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
"required": ["story_id"]
}
},
{
"name": "get_editor_command",
"description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.",
"inputSchema": {
"type": "object",
"properties": {
"worktree_path": {
"type": "string",
"description": "Absolute path to the worktree directory"
}
},
"required": ["worktree_path"]
}
}
]
}),
@@ -601,6 +616,8 @@ async fn handle_tools_call(
"create_worktree" => tool_create_worktree(&args, ctx).await,
"list_worktrees" => tool_list_worktrees(ctx),
"remove_worktree" => tool_remove_worktree(&args, ctx).await,
// Editor tools
"get_editor_command" => tool_get_editor_command(&args, ctx),
_ => Err(format!("Unknown tool: {tool_name}")),
};
@@ -945,6 +962,20 @@ async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result<String,
Ok(format!("Worktree for story '{story_id}' removed."))
}
// ── Editor tool implementations ───────────────────────────────────
fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, String> {
let worktree_path = args
.get("worktree_path")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let editor = get_editor_command_from_store(ctx)
.ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?;
Ok(format!("{editor} {worktree_path}"))
}
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
/// summaries, or `None` if git is unavailable or there are no new commits.
async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
@@ -1093,7 +1124,8 @@ mod tests {
assert!(names.contains(&"create_worktree"));
assert!(names.contains(&"list_worktrees"));
assert!(names.contains(&"remove_worktree"));
assert_eq!(tools.len(), 16);
assert!(names.contains(&"get_editor_command"));
assert_eq!(tools.len(), 17);
}
#[test]
@@ -1338,4 +1370,68 @@ mod tests {
// commits key present (may be null since no real worktree)
assert!(parsed.get("commits").is_some());
}
// ── Editor command tool tests ─────────────────────────────────
#[test]
fn tool_get_editor_command_missing_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_get_editor_command(&json!({}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktree_path"));
}
#[test]
fn tool_get_editor_command_no_editor_configured() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_get_editor_command(
&json!({"worktree_path": "/some/path"}),
&ctx,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No editor configured"));
}
#[test]
fn tool_get_editor_command_formats_correctly() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
ctx.store.set("editor_command", json!("zed"));
let result = tool_get_editor_command(
&json!({"worktree_path": "/home/user/worktrees/37_my_story"}),
&ctx,
)
.unwrap();
assert_eq!(result, "zed /home/user/worktrees/37_my_story");
}
#[test]
fn tool_get_editor_command_works_with_vscode() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
ctx.store.set("editor_command", json!("code"));
let result = tool_get_editor_command(
&json!({"worktree_path": "/path/to/worktree"}),
&ctx,
)
.unwrap();
assert_eq!(result, "code /path/to/worktree");
}
#[test]
fn get_editor_command_in_tools_list() {
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools.iter().find(|t| t["name"] == "get_editor_command");
assert!(tool.is_some(), "get_editor_command missing from tools list");
let t = tool.unwrap();
assert!(t["description"].is_string());
let required = t["inputSchema"]["required"].as_array().unwrap();
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(req_names.contains(&"worktree_path"));
}
}

View File

@@ -8,6 +8,7 @@ pub mod health;
pub mod io;
pub mod mcp;
pub mod model;
pub mod settings;
pub mod workflow;
pub mod project;
@@ -23,6 +24,7 @@ use poem::EndpointExt;
use poem::{Route, get, post};
use poem_openapi::OpenApiService;
use project::ProjectApi;
use settings::SettingsApi;
use std::sync::Arc;
use workflow::WorkflowApi;
@@ -58,6 +60,7 @@ type ApiTuple = (
ChatApi,
WorkflowApi,
AgentsApi,
SettingsApi,
);
type ApiService = OpenApiService<ApiTuple, ()>;
@@ -72,6 +75,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() },
);
let api_service =
@@ -84,7 +88,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx: ctx.clone() },
AgentsApi { ctx },
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx },
);
let docs_service =

143
server/src/http/settings.rs Normal file
View File

@@ -0,0 +1,143 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::store::StoreOps;
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Serialize;
use serde_json::json;
use std::sync::Arc;
const EDITOR_COMMAND_KEY: &str = "editor_command";
#[derive(Tags)]
enum SettingsTags {
Settings,
}
#[derive(Object)]
struct EditorCommandPayload {
editor_command: Option<String>,
}
#[derive(Object, Serialize)]
struct EditorCommandResponse {
editor_command: Option<String>,
}
pub struct SettingsApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "SettingsTags::Settings")]
impl SettingsApi {
/// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set.
#[oai(path = "/settings/editor", method = "get")]
async fn get_editor(&self) -> OpenApiResult<Json<EditorCommandResponse>> {
let editor_command = self
.ctx
.store
.get(EDITOR_COMMAND_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()));
Ok(Json(EditorCommandResponse { editor_command }))
}
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
/// Pass null or empty string to clear the preference.
#[oai(path = "/settings/editor", method = "put")]
async fn set_editor(
&self,
payload: Json<EditorCommandPayload>,
) -> OpenApiResult<Json<EditorCommandResponse>> {
let editor_command = payload.0.editor_command;
let trimmed = editor_command.as_deref().map(str::trim).filter(|s| !s.is_empty());
match trimmed {
Some(cmd) => {
self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd));
self.ctx.store.save().map_err(bad_request)?;
Ok(Json(EditorCommandResponse {
editor_command: Some(cmd.to_string()),
}))
}
None => {
self.ctx.store.delete(EDITOR_COMMAND_KEY);
self.ctx.store.save().map_err(bad_request)?;
Ok(Json(EditorCommandResponse {
editor_command: None,
}))
}
}
}
}
pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
ctx.store
.get(EDITOR_COMMAND_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use tempfile::TempDir;
fn test_ctx(dir: &TempDir) -> AppContext {
AppContext::new_test(dir.path().to_path_buf())
}
#[test]
fn editor_command_defaults_to_null() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let result = get_editor_command_from_store(&ctx);
assert!(result.is_none());
}
#[test]
fn set_editor_command_persists_in_store() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
ctx.store.save().unwrap();
let result = get_editor_command_from_store(&ctx);
assert_eq!(result, Some("zed".to_string()));
}
#[test]
fn get_editor_command_from_store_returns_value() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
ctx.store.set(EDITOR_COMMAND_KEY, json!("code"));
let result = get_editor_command_from_store(&ctx);
assert_eq!(result, Some("code".to_string()));
}
#[test]
fn delete_editor_command_returns_none() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor"));
ctx.store.delete(EDITOR_COMMAND_KEY);
let result = get_editor_command_from_store(&ctx);
assert!(result.is_none());
}
#[test]
fn editor_command_survives_reload() {
let dir = TempDir::new().unwrap();
let store_path = dir.path().join(".story_kit_store.json");
{
let ctx = AppContext::new_test(dir.path().to_path_buf());
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
ctx.store.save().unwrap();
}
// Reload from disk
let store2 = crate::store::JsonFileStore::new(store_path).unwrap();
let val = store2.get(EDITOR_COMMAND_KEY);
assert_eq!(val, Some(json!("zed")));
}
}