Skip to content

Commit 1e9d9f3

Browse files
committed
fix(build): unify slash registry; add builtins (allow, mcp-add, todo, compact, autocompact); make hooks async-trait object-safe; adjust HookEvent PostExec usage; compact utf8 fix; config manager reload_all interior-mutability; packaging exclude list + async-trait dep
1 parent fbcde9d commit 1e9d9f3

File tree

6 files changed

+117
-183
lines changed

6 files changed

+117
-183
lines changed

Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ include = [
1919
"LICENSE",
2020
"THIRD_PARTY_NOTICES.md"
2121
]
22+
# Fallback exclusions (in case include is ignored by tooling)
23+
exclude = [
24+
"/external/**",
25+
"/xtask/**",
26+
"/.github/**",
27+
"/target/**",
28+
"/schemas/**",
29+
"/scripts/**",
30+
"/.gitmodules",
31+
"/Makefile",
32+
"/.codex/**",
33+
"/Cargo.toml.orig"
34+
]
2235

2336
[workspace]
2437
members = [".", "xtask"]
@@ -50,6 +63,7 @@ uuid = { version = ">=1.18.0", features = ["v4"] }
5063
chrono = { version = ">=0.4.41", default-features = false, features = ["clock"] }
5164
ratatui = ">=0.29"
5265
crossterm = ">=0.29"
66+
async-trait = "0.1"
5367

5468
toml = ">=0.9.6"
5569
tracing = ">=0.1"

src/compact.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ fn git_changed_files(repo: &Repository, root: &Path) -> BTreeSet<PathBuf> {
9595
// Index (staged)
9696
if let Ok(idx) = repo.index() {
9797
for e in idx.iter() {
98-
if let Some(path) = std::str::from_utf8(e.path).ok() {
98+
if let Some(path) = std::str::from_utf8(&e.path).ok() {
9999
out.insert(root.join(path));
100100
}
101101
}

src/hooks.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ pub struct HookRule {
5353
}
5454
fn default_true() -> bool { true }
5555

56-
#[derive(Default)]
5756
pub struct HookRegistry {
5857
rules: Vec<HookRule>,
5958
depth: Arc<Mutex<usize>>,

src/layered_config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ impl ConfigManager {
278278
system_path, user_path, workspace_path,
279279
runtime_overlay: Arc::new(RwLock::new(Config::default())),
280280
};
281-
let mut me = cm;
281+
let me = cm;
282282
me.reload_all()?;
283283
me.start_watch()?;
284284
Ok(me)
@@ -290,7 +290,7 @@ impl ConfigManager {
290290
Some(p.0)
291291
}
292292

293-
pub fn reload_all(&mut self) -> Result<()> {
293+
pub fn reload_all(&self) -> Result<()> {
294294
let mut merged = Config::default();
295295
if let Some(sys) = Self::read_file(&self.system_path) { merge(&mut merged, &sys); }
296296
if let Some(usr) = Self::read_file(&self.user_path) { merge(&mut merged, &usr); }

src/slash.rs

Lines changed: 99 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ use anyhow::{anyhow, Result};
44
use serde::Deserialize;
55
use std::{collections::BTreeMap, fs, path::PathBuf, sync::Arc};
66

7-
use crate::layered_config::{Config, ConfigManager, Scope};
7+
use crate::{
8+
layered_config::{Config, ConfigManager, Scope},
9+
compact::Compactor,
10+
todo::{TodoStore, TodoStatus},
11+
};
812

913
#[derive(Clone)]
1014
pub struct SlashRegistry {
1115
aliases: BTreeMap<String, String>,
1216
macros: BTreeMap<String, Vec<String>>,
1317
builtins: BTreeMap<String, BTreeMap<String, String>>, // name -> args
1418
cfg: Arc<ConfigManager>,
19+
workspace_root: PathBuf,
1520
}
1621

1722
#[derive(Default, Deserialize)]
@@ -28,7 +33,7 @@ struct SlashTomlFile {
2833
struct SlashMacro { name: String, lines: Vec<String> }
2934

3035
impl SlashRegistry {
31-
pub fn load_from_dirs(cfg: Arc<ConfigManager>, dirs: &[PathBuf]) -> Result<Self> {
36+
pub fn load_from_dirs_with_workspace(cfg: Arc<ConfigManager>, workspace_root: PathBuf, dirs: &[PathBuf]) -> Result<Self> {
3237
let mut aliases = BTreeMap::new();
3338
let mut macros: BTreeMap<String, Vec<String>> = BTreeMap::new();
3439
let mut builtins: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
@@ -45,7 +50,13 @@ impl SlashRegistry {
4550
}
4651
}
4752
}
48-
Ok(Self { aliases, macros, builtins, cfg })
53+
Ok(Self { aliases, macros, builtins, cfg, workspace_root })
54+
}
55+
56+
// Backwards-compatible helper: default workspace is current dir
57+
pub fn load_from_dirs(cfg: Arc<ConfigManager>, dirs: &[PathBuf]) -> Result<Self> {
58+
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
59+
Self::load_from_dirs_with_workspace(cfg, cwd, dirs)
4960
}
5061

5162
pub async fn dispatch(&self, input: &str) -> Result<String> {
@@ -81,187 +92,97 @@ impl SlashRegistry {
8192
self.cfg.apply_runtime_overlay(patch)?;
8293
Ok("runtime overlay applied".into())
8394
}
84-
_ => Ok(format!("builtin:{} {}", name, serde_json::to_string(args)?)),
85-
}
86-
}
87-
}
88-
89-
// annex/src/slash.rs content below
90-
91-
use anyhow::{anyhow, Result};
92-
use std::{path::PathBuf, sync::Arc};
93-
94-
use crate::{
95-
layered_config::{Config, ConfigManager, Scope},
96-
mcp_runtime::McpRuntime,
97-
todo::{TodoStore, TodoStatus},
98-
compact::{Compactor, AutoCompactStage},
99-
};
100-
101-
#[derive(Clone)]
102-
pub struct SlashRegistry {
103-
cmds: Vec<Arc<dyn SlashCommand>>,
104-
}
105-
impl SlashRegistry {
106-
pub fn new() -> Self { Self { cmds: vec![] } }
107-
pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) { self.cmds.push(cmd); }
108-
pub async fn dispatch(&self, input: &str) -> Result<String> {
109-
let input = input.trim();
110-
if !input.starts_with('/') { return Err(anyhow!("not a slash command")); }
111-
let parts: Vec<&str> = input[1..].split_whitespace().collect();
112-
if parts.is_empty() { return Err(anyhow!("empty command")); }
113-
let name = parts[0];
114-
for c in &self.cmds { if c.name() == name { return c.run(parts[1..].join(" ")).await; } }
115-
Err(anyhow!("unknown command: {}", name))
116-
}
117-
}
118-
119-
#[async_trait::async_trait]
120-
pub trait SlashCommand: Send + Sync {
121-
fn name(&self) -> &'static str;
122-
async fn run(&self, args: String) -> Result<String>;
123-
}
124-
125-
/*** Existing minimal commands from earlier snippet (not re-listed for brevity) ***/
126-
pub struct AllowCommand { pub cfg: Arc<ConfigManager> }
127-
#[async_trait::async_trait]
128-
impl SlashCommand for AllowCommand {
129-
fn name(&self) -> &'static str { "allow" }
130-
async fn run(&self, args: String) -> Result<String> {
131-
let mut patch = Config::default();
132-
patch.shell.allowlist_roots = vec![args.trim().to_string()];
133-
self.cfg.write_patch(Scope::Workspace, &patch)?;
134-
Ok(format!("added to allowlist (workspace): {}", args.trim()))
135-
}
136-
}
137-
pub struct McpAddCommand { pub cfg: Arc<ConfigManager> }
138-
#[async_trait::async_trait]
139-
impl SlashCommand for McpAddCommand {
140-
fn name(&self) -> &'static str { "mcp-add" }
141-
async fn run(&self, args: String) -> Result<String> {
142-
let mut patch = Config::default();
143-
// Simple parser: JSON object {"name":"X","stdio":{"cmd": "...","args":["..."]}} or {"name":"X","tcp":{"host":"...","port":1234}}
144-
let v: serde_json::Value = serde_json::from_str(&args)?;
145-
let name = v.get("name").and_then(|x| x.as_str()).ok_or_else(|| anyhow!("missing name"))?;
146-
let mut m = crate::layered_config::McpServer::default();
147-
m.enabled = true;
148-
if let Some(stdio) = v.get("stdio") {
149-
m.transport = "stdio".into();
150-
m.command = stdio.get("cmd").and_then(|x| x.as_str()).map(|s| s.into());
151-
if let Some(a) = stdio.get("args").and_then(|x| x.as_array()) {
152-
m.args = a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect();
95+
"allow" => {
96+
let root = argstr.trim();
97+
if root.is_empty() { return Err(anyhow!("usage: /allow <root-binary>")); }
98+
let mut patch = Config::default();
99+
patch.shell.allowlist_roots = vec![root.to_string()];
100+
self.cfg.write_patch(Scope::Workspace, &patch)?;
101+
Ok(format!("added to allowlist (workspace): {}", root))
153102
}
154-
} else if let Some(tcp) = v.get("tcp") {
155-
m.transport = "tcp".into();
156-
m.host = tcp.get("host").and_then(|x| x.as_str()).map(|s| s.into());
157-
m.port = tcp.get("port").and_then(|x| x.as_u64()).map(|n| n as u16);
158-
} else {
159-
return Err(anyhow!("expect stdio or tcp"));
160-
}
161-
patch.mcp.servers.insert(name.into(), m);
162-
self.cfg.write_patch(Scope::Workspace, &patch)?;
163-
Ok("MCP server added (workspace)".into())
164-
}
165-
}
166-
pub struct ConfigSetCommand { pub cfg: Arc<ConfigManager> }
167-
#[async_trait::async_trait]
168-
impl SlashCommand for ConfigSetCommand {
169-
fn name(&self) -> &'static str { "config-set" }
170-
async fn run(&self, args: String) -> Result<String> {
171-
let parts: Vec<&str> = args.split_whitespace().collect();
172-
if parts.len() < 2 { return Err(anyhow!("usage: /config-set <path> <value>")); }
173-
let path = parts[0]; let value = parts[1..].join(" ");
174-
let mut patch = Config::default();
175-
match path {
176-
"model.name" => patch.model.name = Some(value),
177-
"history.persist" => patch.history.persist = Some(value),
178-
"sandbox.mode" => patch.sandbox.mode = Some(value),
179-
"sandbox.network_access" => patch.sandbox.network_access = Some(value.parse::<bool>()?),
180-
_ => return Err(anyhow!("unsupported path: {}", path)),
181-
}
182-
self.cfg.apply_runtime_overlay(patch)?;
183-
Ok("runtime overlay applied".into())
184-
}
185-
}
186-
187-
/*** NEW: TODO commands ***/
188-
pub struct TodoCommand { pub cfg: Arc<ConfigManager>, pub workspace: PathBuf }
189-
#[async_trait::async_trait]
190-
impl SlashCommand for TodoCommand {
191-
fn name(&self) -> &'static str { "todo" }
192-
async fn run(&self, args: String) -> Result<String> {
193-
let cfg = self.cfg.get();
194-
let path = cfg.todo.path.clone().unwrap_or(self.workspace.join(".codex").join("todo.json"));
195-
let mut store = TodoStore::load(&path)?;
196-
let parts: Vec<&str> = args.split_whitespace().collect();
197-
match parts.get(0).map(|s| *s).unwrap_or("") {
198-
"add" => {
199-
// /todo add {"title":"…","description":"…","files":["path1","path2"],"tags":["x"]}
200-
let v: serde_json::Value = serde_json::from_str(parts[1..].join(" ").trim())?;
201-
let title = v.get("title").and_then(|x| x.as_str()).ok_or_else(|| anyhow!("title required"))?;
202-
let desc = v.get("description").and_then(|x| x.as_str()).map(|s| s.to_string());
203-
let files: Vec<PathBuf> = v.get("files").and_then(|x| x.as_array()).unwrap_or(&vec![])
204-
.iter().filter_map(|x| x.as_str().map(|s| self.workspace.join(s))).collect();
205-
let tags: Vec<String> = v.get("tags").and_then(|x| x.as_array()).unwrap_or(&vec![])
206-
.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect();
207-
let it = store.add(title.to_string(), desc, files, tags);
208-
store.save(&path)?;
209-
Ok(format!("todo added: {} ({})", it.title, it.id))
103+
"mcp-add" => {
104+
// JSON: {"name":"X","stdio":{...}} or {"name":"X","tcp":{...}}
105+
let v: serde_json::Value = serde_json::from_str(argstr)?;
106+
let name = v.get("name").and_then(|x| x.as_str()).ok_or_else(|| anyhow!("missing name"))?;
107+
let mut m = crate::layered_config::McpServer::default();
108+
m.enabled = true;
109+
if let Some(stdio) = v.get("stdio") {
110+
m.transport = "stdio".into();
111+
m.command = stdio.get("cmd").and_then(|x| x.as_str()).map(|s| s.into());
112+
if let Some(a) = stdio.get("args").and_then(|x| x.as_array()) {
113+
m.args = a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect();
114+
}
115+
} else if let Some(tcp) = v.get("tcp") {
116+
m.transport = "tcp".into();
117+
m.host = tcp.get("host").and_then(|x| x.as_str()).map(|s| s.into());
118+
m.port = tcp.get("port").and_then(|x| x.as_u64()).map(|n| n as u16);
119+
} else { return Err(anyhow!("expect stdio or tcp")); }
120+
let mut patch = Config::default();
121+
patch.mcp.servers.insert(name.into(), m);
122+
self.cfg.write_patch(Scope::Workspace, &patch)?;
123+
Ok("MCP server added (workspace)".into())
210124
}
211-
"list" => {
212-
let mut s = String::new();
213-
for it in &store.items {
214-
s.push_str(&format!("- [{}] {} ({}) {:?}\n", match it.status { TodoStatus::Open=>" ", TodoStatus::InProgress=>">", TodoStatus::Done=>"x" }, it.title, it.id, it.files));
125+
"todo" => {
126+
// /todo add {json} | list | done <id> | rm <id>
127+
let cfg = self.cfg.get();
128+
let path = cfg.todo.path.clone().unwrap_or(self.workspace_root.join(".codex").join("todo.json"));
129+
let mut store = TodoStore::load(&path)?;
130+
let parts: Vec<&str> = argstr.split_whitespace().collect();
131+
match parts.get(0).copied().unwrap_or("") {
132+
"add" => {
133+
let v: serde_json::Value = serde_json::from_str(parts[1..].join(" ").trim())?;
134+
let title = v.get("title").and_then(|x| x.as_str()).ok_or_else(|| anyhow!("title required"))?;
135+
let desc = v.get("description").and_then(|x| x.as_str()).map(|s| s.to_string());
136+
let files: Vec<PathBuf> = v.get("files").and_then(|x| x.as_array()).unwrap_or(&vec![])
137+
.iter().filter_map(|x| x.as_str().map(|s| self.workspace_root.join(s))).collect();
138+
let tags: Vec<String> = v.get("tags").and_then(|x| x.as_array()).unwrap_or(&vec![])
139+
.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect();
140+
let it = store.add(title.to_string(), desc, files, tags);
141+
store.save(&path)?;
142+
Ok(format!("todo added: {} ({})", it.title, it.id))
143+
}
144+
"list" => {
145+
let mut s = String::new();
146+
for it in &store.items {
147+
s.push_str(&format!("- [{}] {} ({}) {:?}\n", match it.status { TodoStatus::Open=>" ", TodoStatus::InProgress=>">", TodoStatus::Done=>"x" }, it.title, it.id, it.files));
148+
}
149+
Ok(s)
150+
}
151+
"done" => {
152+
let id = parts.get(1).ok_or_else(|| anyhow!("usage: /todo done <id>"))?;
153+
store.set_status(id, TodoStatus::Done)?; store.save(&path)?;
154+
Ok(format!("todo {} marked done", id))
155+
}
156+
"rm" => {
157+
let id = parts.get(1).ok_or_else(|| anyhow!("usage: /todo rm <id>"))?;
158+
store.remove(id)?; store.save(&path)?;
159+
Ok(format!("todo {} removed", id))
160+
}
161+
_ => Err(anyhow!("usage: /todo [add|list|done|rm] …")),
215162
}
216-
Ok(s)
217163
}
218-
"done" => {
219-
let id = parts.get(1).ok_or_else(|| anyhow!("usage: /todo done <id>"))?;
220-
store.set_status(id, TodoStatus::Done)?;
221-
store.save(&path)?;
222-
Ok(format!("todo {} marked done", id))
164+
"compact" => {
165+
// args JSON: {"focus":"…","include":["glob1"],"conversation_tail":"…"}
166+
let v: serde_json::Value = serde_json::from_str(argstr.trim())?;
167+
let focus = v.get("focus").and_then(|x| x.as_str()).map(|s| s.to_string());
168+
let includes: Vec<String> = v.get("include").and_then(|x| x.as_array()).unwrap_or(&vec![])
169+
.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect();
170+
let tail = v.get("conversation_tail").and_then(|x| x.as_str()).unwrap_or("");
171+
let comp = Compactor::new(self.cfg.clone(), self.workspace_root.clone());
172+
let res = comp.manual_compact(focus, includes, tail)?;
173+
Ok(serde_json::to_string_pretty(&res)?)
223174
}
224-
"rm" => {
225-
let id = parts.get(1).ok_or_else(|| anyhow!("usage: /todo rm <id>"))?;
226-
store.remove(id)?;
227-
store.save(&path)?;
228-
Ok(format!("todo {} removed", id))
175+
"autocompact" => {
176+
let mut patch = Config::default();
177+
match argstr.trim() {
178+
"on" => { patch.compact.auto_enable = true; }
179+
"off" => { patch.compact.auto_enable = false; }
180+
_ => return Err(anyhow!("usage: /autocompact on|off")),
181+
}
182+
self.cfg.apply_runtime_overlay(patch)?;
183+
Ok(format!("auto-compact {}", argstr.trim()))
229184
}
230-
_ => Err(anyhow!("usage: /todo [add|list|done|rm] …")),
231-
}
232-
}
233-
}
234-
235-
/*** NEW: compact + autocompact ***/
236-
pub struct CompactCommand { pub cfg: Arc<ConfigManager>, pub workspace: PathBuf }
237-
#[async_trait::async_trait]
238-
impl SlashCommand for CompactCommand {
239-
fn name(&self) -> &'static str { "compact" }
240-
async fn run(&self, args: String) -> Result<String> {
241-
// JSON input: {"focus":"…","include":["glob1","glob2"],"conversation_tail":"…"}
242-
let v: serde_json::Value = serde_json::from_str(args.trim())?;
243-
let focus = v.get("focus").and_then(|x| x.as_str()).map(|s| s.to_string());
244-
let includes: Vec<String> = v.get("include").and_then(|x| x.as_array()).unwrap_or(&vec![])
245-
.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect();
246-
let tail = v.get("conversation_tail").and_then(|x| x.as_str()).unwrap_or("");
247-
let comp = crate::compact::Compactor::new(self.cfg.clone(), self.workspace.clone());
248-
let res = comp.manual_compact(focus, includes, tail)?;
249-
Ok(serde_json::to_string_pretty(&res)?)
250-
}
251-
}
252-
253-
pub struct AutoCompactToggle { pub cfg: Arc<ConfigManager> }
254-
#[async_trait::async_trait]
255-
impl SlashCommand for AutoCompactToggle {
256-
fn name(&self) -> &'static str { "autocompact" }
257-
async fn run(&self, args: String) -> Result<String> {
258-
let mut patch = Config::default();
259-
match args.trim() {
260-
"on" => { patch.compact.auto_enable = true; }
261-
"off" => { patch.compact.auto_enable = false; }
262-
_ => return Err(anyhow!("usage: /autocompact on|off")),
185+
_ => Ok(format!("builtin:{} {}", name, serde_json::to_string(args)?)),
263186
}
264-
self.cfg.apply_runtime_overlay(patch)?;
265-
Ok(format!("auto-compact {}", args.trim()))
266187
}
267188
}

src/taskset.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ impl<'a> TaskSetRunner<'a> {
139139
TaskStep::Exec { cmd, args } => {
140140
let (status, out_preview) = (self.do_exec)(cmd, args).await?;
141141
let _ = self.ui_tx.send(UiEvent::TaskProgress { set_id: set.set_id.clone(), task_id: t.id.clone(), line: format!("exec {} -> {}", cmd, status) });
142-
self.hooks.emit(&self.ctx, &HookEvent::PostExec{ cmd: cmd.clone(), argv: args.clone(), status }).await.ok();
142+
self.hooks.emit(&self.ctx, &HookEvent::PostExec{ cmd: cmd.clone(), argv: args.clone(), status, stdout_len: out_preview.len(), stderr_len: 0 }).await.ok();
143143
if status != 0 { ok = false; }
144144
}
145145
TaskStep::McpCall { server, method, payload } => {

0 commit comments

Comments
 (0)