Modul-System (Registry Backend+Frontend, generierte Tabs, Settings-Sektion); ToDo-Modul: persistente Liste mit Anlegen/Bearbeiten im Panel (todos_add/todos_update, Formular mit Abbrechen); alte Hook-Todoliste (OFFENE-PUNKTE.md) komplett entfernt

This commit is contained in:
marcus hinz
2026-07-23 11:35:29 +02:00
parent 513e1a768f
commit 1523856ea7
40 changed files with 1832 additions and 600 deletions
+1 -1
View File
@@ -6,12 +6,12 @@ pub(crate) mod archive;
pub(crate) mod archive_index;
pub(crate) mod archive_search;
pub(crate) mod credentials;
pub(crate) mod modules;
pub(crate) mod paths;
pub(crate) mod pool;
pub(crate) mod project;
pub(crate) mod registry;
pub(crate) mod settings;
pub(crate) mod todo;
pub(crate) mod usage;
pub(crate) mod watcher;
+294
View File
@@ -0,0 +1,294 @@
//! Modul-Registry: benennt die Funktionsblöcke der App (Entwurf, Befehle,
//! Archiv, …) mit ihren Beiträgen — MCP-Tools und Puffer-Kanäle. Die Tabelle
//! ist die eine Quelle für „was ist in diesem Projekt an?": Das Frontend baut
//! seine Tabs daraus (`enabled_modules`), der MCP-Server seine Tool-Liste
//! (tools/list) und den Guard vor dem Dispatch (tools/call).
//!
//! Die Abwahl eines Moduls ist Konfiguration, keine Sicherheitsgrenze —
//! Commands bleiben registriert, die Capability-Manifeste unverändert;
//! dieselbe Linie wie bei den Pools (Konfiguration trennen, nicht Zugriff).
use crate::domain::paths::Paths;
/// Ein Puffer-Kanal eines Moduls: MCP-Server oder Command schreibt die Datei,
/// der Watcher im Terminal-Prozess meldet neuen Inhalt als Event an die
/// Panel-Fenster.
pub(crate) struct BufferDesc {
/// Schlüssel für `buffer_read` (Erstbefüllung der Ansicht).
pub(crate) id: &'static str,
/// Env-Variable, unter der die PTY den Dateipfad an claudes MCP-Kinder gibt.
pub(crate) env: &'static str,
/// Event an die Panel-Fenster bei Dateiänderung.
pub(crate) event: &'static str,
/// Pufferdatei des Projekts.
pub(crate) file: fn(&str) -> std::path::PathBuf,
/// Dateiname-Suffix (`<projekt>.<suffix>`) — muss zu `file` passen
/// (Test buffer_datei_passt_zum_suffix); Grundlage der Löschvorschau.
pub(crate) suffix: &'static str,
/// Persistente Puffer überleben Sessions — der Session-Start legt sie nur
/// an, statt sie zu leeren.
pub(crate) persistent: bool,
}
pub(crate) struct ModuleDesc {
pub(crate) id: &'static str,
/// Kern-Module sind nicht abschaltbar; ein Config-Eintrag wird ignoriert.
pub(crate) core: bool,
/// Aktiv ohne Eintrag in der Projekt-Config.
pub(crate) default_enabled: bool,
/// Modul setzt ein konfiguriertes Archiv-Home voraus — ohne Home fallen
/// seine Tabs im Frontend weg. Seine MCP-Tools bleiben absichtlich
/// gelistet: Das Home kann mitten in der Session gesetzt werden, und die
/// Tools melden das fehlende Home selbst verständlich zurück.
pub(crate) requires_archive: bool,
pub(crate) mcp_tools: &'static [&'static str],
pub(crate) buffers: &'static [BufferDesc],
}
pub(crate) const MODULES: &[ModuleDesc] = &[
ModuleDesc {
id: "draft",
core: true,
default_enabled: true,
requires_archive: false,
mcp_tools: &["write_panel"],
buffers: &[BufferDesc {
id: "panel",
env: "AI_CONTROL_PANEL",
event: "panel-update",
file: crate::domain::paths::panel_file,
suffix: "md",
persistent: false,
}],
},
ModuleDesc {
id: "commands",
core: false,
default_enabled: true,
requires_archive: false,
mcp_tools: &["write_commands", "show_commands"],
buffers: &[BufferDesc {
id: "commands",
env: "AI_CONTROL_COMMANDS",
event: "commands-update",
file: crate::domain::paths::commands_file,
suffix: "commands.jsonl",
persistent: false,
}],
},
ModuleDesc {
id: "todo",
core: false,
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
default_enabled: false,
requires_archive: false,
mcp_tools: &["write_todos", "show_todos"],
buffers: &[BufferDesc {
id: "todos",
env: "AI_CONTROL_TODOS",
event: "todos-update",
file: crate::domain::paths::todos_file,
suffix: "todos.jsonl",
persistent: true,
}],
},
ModuleDesc {
id: "archive",
core: false,
default_enabled: true,
requires_archive: true,
mcp_tools: &["archive_panel", "show_archive", "search_archive"],
buffers: &[
BufferDesc {
id: "search",
env: "AI_CONTROL_SEARCH",
event: "search-update",
file: crate::domain::paths::search_file,
suffix: "search.json",
persistent: false,
},
BufferDesc {
id: "wiki",
env: "AI_CONTROL_WIKI",
event: "wiki-update",
file: crate::domain::paths::wiki_file,
suffix: "wiki.json",
persistent: false,
},
],
},
];
/// Aktive Module des Projekts: MODULES, gefiltert durch die `modules`-
/// Abweichungen der Projekt-Config. Kern-Module sind immer dabei.
pub(crate) fn active_in(
paths: &Paths,
project: &str,
) -> Result<Vec<&'static ModuleDesc>, String> {
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
Ok(
MODULES
.iter()
.filter(|m| {
m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled)
})
.collect(),
)
}
/// Modul, das dieses MCP-Tool beiträgt.
pub(crate) fn by_tool(tool: &str) -> Option<&'static ModuleDesc> {
MODULES.iter().find(|m| m.mcp_tools.contains(&tool))
}
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
/// Config-Schalter (ohne `requires_archive` — die Pfadfrage zeigt der
/// Dialog daneben an).
#[derive(serde::Serialize)]
pub(crate) struct ModuleInfo {
pub(crate) id: &'static str,
pub(crate) core: bool,
pub(crate) enabled: bool,
}
pub(crate) fn module_infos_in(
paths: &Paths,
project: &str,
) -> Result<Vec<ModuleInfo>, String> {
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
Ok(
MODULES
.iter()
.map(|m| ModuleInfo {
id: m.id,
core: m.core,
enabled: m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled),
})
.collect(),
)
}
/// Schreibt die Modul-Abweichung in die Projekt-Config; der Default-Wert
/// löscht den Eintrag (nur Abweichungen werden gespeichert).
pub(crate) fn set_module_in(
paths: &Paths,
project: &str,
module: &str,
enabled: bool,
) -> Result<(), String> {
let m = MODULES
.iter()
.find(|m| m.id == module)
.ok_or_else(|| format!("unbekanntes Modul: {module}"))?;
if m.core {
return Err(format!("Modul {module} ist nicht abschaltbar"));
}
let mut cfg = crate::domain::project::read_project_config_in(paths, project)?;
if enabled == m.default_enabled {
cfg.modules.remove(module);
} else {
cfg.modules.insert(module.to_string(), enabled);
}
crate::domain::project::write_project_config_in(paths, project, &cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::{create_project, tmp_paths};
fn ids(mods: &[&'static ModuleDesc]) -> Vec<&'static str> {
mods.iter().map(|m| m.id).collect()
}
#[test]
fn defaults_alle_aktiv() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
assert_eq!(
ids(&active_in(&p, "proj").unwrap()),
vec!["draft", "commands", "archive"]
);
}
#[test]
fn config_schaltet_ab_kern_bleibt() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let cfg = p
.projects_dir()
.join("proj")
.join(".ai-control")
.join("config.json");
std::fs::write(
&cfg,
r#"{"id": "proj", "name": "proj", "modules": {"commands": false, "draft": false}}"#,
)
.unwrap();
// commands ist ab; draft ignoriert den Eintrag (Kern-Modul).
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
}
#[test]
fn unbekanntes_projekt_scheitert() {
let p = tmp_paths();
assert!(active_in(&p, "fehlt").is_err());
}
#[test]
fn todo_ist_opt_in() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
set_module_in(&p, "proj", "todo", true).unwrap();
assert_eq!(
ids(&active_in(&p, "proj").unwrap()),
vec!["draft", "commands", "todo", "archive"]
);
}
#[test]
fn buffer_datei_passt_zum_suffix() {
for b in MODULES.iter().flat_map(|m| m.buffers) {
let name = (b.file)("proj");
let name = name.file_name().unwrap().to_str().unwrap();
assert_eq!(name, format!("proj.{}", b.suffix));
}
}
#[test]
fn set_module_roundtrip() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
set_module_in(&p, "proj", "commands", false).unwrap();
assert_eq!(ids(&active_in(&p, "proj").unwrap()), vec!["draft", "archive"]);
assert!(!module_infos_in(&p, "proj").unwrap()[1].enabled);
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
set_module_in(&p, "proj", "commands", true).unwrap();
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
assert!(cfg.modules.is_empty());
// Kern-Modul und unbekannte ID scheitern laut.
assert!(set_module_in(&p, "proj", "draft", false).is_err());
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
}
#[test]
fn tool_zuordnung() {
assert_eq!(by_tool("write_panel").unwrap().id, "draft");
assert_eq!(by_tool("search_archive").unwrap().id, "archive");
assert!(by_tool("unbekannt").is_none());
}
#[test]
fn buffer_und_tool_ids_eindeutig() {
let mut tools: Vec<&str> = MODULES.iter().flat_map(|m| m.mcp_tools).copied().collect();
let mut buffers: Vec<&str> =
MODULES.iter().flat_map(|m| m.buffers).map(|b| b.id).collect();
let (t, b) = (tools.len(), buffers.len());
tools.sort();
tools.dedup();
buffers.sort();
buffers.dedup();
assert_eq!(tools.len(), t);
assert_eq!(buffers.len(), b);
}
}
+9
View File
@@ -98,6 +98,15 @@ pub(crate) fn wiki_file(project: &str) -> PathBuf {
.join(format!("{}.wiki.json", checked(project)))
}
/// Persistente ToDo-Liste eines Projekts (JSONL, anhängend — überlebt
/// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
/// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
pub(crate) fn todos_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{}.todos.jsonl", checked(project)))
}
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
if p == "~" {
+28 -44
View File
@@ -61,6 +61,11 @@ pub(crate) struct ProjectConfig {
skip_serializing_if = "Option::is_none"
)]
pub(crate) archive_home: Option<String>,
/// Modul-Abweichungen vom Default (`"commands": false`); fehlender Key =
/// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
/// den Eintrag.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub(crate) modules: std::collections::BTreeMap<String, bool>,
/// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht.
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
/// Einstellungen) still die Felder neuerer Versionen: serde verwirft
@@ -241,7 +246,6 @@ pub(crate) fn project_config(project: &str) -> Result<ProjectConfig, String> {
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_full_in(
paths: &Paths,
name: &str,
@@ -250,7 +254,6 @@ pub(crate) fn create_project_full_in(
work_dir: Option<&str>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<String, String> {
check_name(name)?;
let dir = match dir {
@@ -312,12 +315,10 @@ pub(crate) fn create_project_full_in(
name: Some(name.to_string()),
terminal,
archive_home: None,
modules: Default::default(),
rest: Default::default(),
};
write_project_config_in(paths, &id, &cfg)?;
if todo {
crate::domain::todo::set_todo_in(paths, &id, true)?;
}
crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(id)
}
@@ -484,7 +485,6 @@ pub(crate) struct DeletePreview {
pub(crate) project_dir: String,
pub(crate) ai_control_dir: bool,
pub(crate) archive_permission: bool,
pub(crate) todo_hook: bool,
pub(crate) panel_files: usize,
pub(crate) archive_home: Option<String>,
pub(crate) archive_docs: usize,
@@ -511,7 +511,6 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
project_dir: contract_home(paths, &dir),
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
archive_permission: cfg.archive_home.is_some(),
todo_hook: crate::domain::todo::todo_state_in(paths, project)?,
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
archive_home: cfg.archive_home.clone(),
archive_docs,
@@ -520,22 +519,22 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
}
/// Flüchtige Panel-Kanaldateien des Projekts unter ~/.config/ai-control/panels.
fn session_files(paths: &Paths, project: &str) -> [PathBuf; 4] {
/// Panel-Kanaldateien des Projekts — aus der Modul-Registry, damit neue
/// Puffer automatisch in Löschvorschau und Löschung landen.
fn session_files(paths: &Paths, project: &str) -> Vec<PathBuf> {
let panels = paths.config_dir().join("panels");
[
panels.join(format!("{project}.md")),
panels.join(format!("{project}.commands.jsonl")),
panels.join(format!("{project}.search.json")),
panels.join(format!("{project}.wiki.json")),
]
crate::domain::modules::MODULES
.iter()
.flat_map(|m| m.buffers)
.map(|b| panels.join(format!("{project}.{}", b.suffix)))
.collect()
}
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
/// vorige ein):
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
/// Archiv-Rechte und Todo-Hook in der settings.json, Panel-Dateien,
/// .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst der
/// settings.json bleiben.
/// Archiv-Rechte in der settings.json, Panel-Dateien, .desktop. Ordner,
/// memory/ und das Claude-Code-Grundgerüst der settings.json bleiben.
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
pub(crate) fn delete_project_scoped_in(
@@ -567,9 +566,6 @@ pub(crate) fn delete_project_scoped_in(
if let Some(a) = cfg.archive_home.as_deref() {
crate::domain::archive::remove_archive_permission(paths, project, a)?;
}
if crate::domain::todo::todo_state_in(paths, project)? {
crate::domain::todo::set_todo_in(paths, project, false)?;
}
let ac = dir.join(PROJECT_CONFIG_DIR);
if ac.is_dir() {
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
@@ -784,7 +780,6 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
mod tests {
use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
@@ -841,7 +836,6 @@ mod tests {
title: Some("Neu".into()),
..Default::default()
},
true,
)
.unwrap();
@@ -876,21 +870,14 @@ mod tests {
"Edit(~/claude-projects/neu/**)"
);
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
assert!(dir.join(TODO_FILE).is_file());
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.len(), 1);
assert!(groups[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(TODO_FILE));
assert!(settings.get("hooks").is_none());
}
#[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths();
let id =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default())
.unwrap();
let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file());
@@ -915,7 +902,7 @@ mod tests {
let p = tmp_paths();
create_project(&p, "neu").unwrap();
let err =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default())
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
@@ -931,7 +918,6 @@ mod tests {
Some("~/projects/gibtsnicht"),
false,
TerminalConfig::default(),
false,
)
.unwrap_err();
assert!(err.contains("Arbeitsverzeichnis fehlt"));
@@ -1225,7 +1211,7 @@ mod tests {
#[test]
fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
delete_project_scoped_in(&p, &id, "full", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -1235,7 +1221,7 @@ mod tests {
#[test]
fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
delete_project_scoped_in(&p, &id, "full", true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -1245,7 +1231,7 @@ mod tests {
#[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
@@ -1254,7 +1240,7 @@ mod tests {
#[test]
fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap();
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer
@@ -1272,7 +1258,7 @@ mod tests {
#[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
@@ -1294,7 +1280,7 @@ mod tests {
#[test]
fn projektordner_verlegen() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
let new_dir = p.home.join("elsewhere").join("proj");
@@ -1350,7 +1336,7 @@ mod tests {
#[test]
fn loeschen_nur_integration() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), true)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
let dir = p.projects_dir().join("proj");
// Archiv-Home samt Permission wie über die UI gesetzt
@@ -1365,7 +1351,7 @@ mod tests {
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap();
let preview = delete_preview_in(&p, &id).unwrap();
assert!(preview.ai_control_dir && preview.archive_permission && preview.todo_hook);
assert!(preview.ai_control_dir && preview.archive_permission);
assert_eq!(preview.panel_files, 1);
assert_eq!(preview.archive_docs, 1);
@@ -1375,9 +1361,7 @@ mod tests {
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
assert!(!sp.contains("archiv/proj"));
assert!(!sp.contains("OFFENE-PUNKTE.md")); // Hook weg (leere Hook-Liste darf bleiben)
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
assert!(dir.join(crate::domain::todo::TODO_FILE).is_file()); // Nutzerinhalt bleibt
assert!(p.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
assert!(load_registry(&p).unwrap().is_empty());
@@ -1387,7 +1371,7 @@ mod tests {
#[test]
fn loeschen_integration_und_archiv() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap();
let mut cfg = read_project_config_in(&p, &id).unwrap();
cfg.archive_home = Some("~/archiv/proj".into());
-166
View File
@@ -1,166 +0,0 @@
//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per
//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert.
use std::fs;
use crate::domain::check_name;
use crate::domain::paths::Paths;
use crate::domain::project::settings_path;
use crate::domain::registry::project_dir;
pub(crate) const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
/// Der Hook landet in der `settings.json` des Projekts und wird von Claude Code
/// bei jedem Sessionstart über die Shell ausgeführt. Der Pfad muss darum
/// gequotet werden: Er stammt aus dem Ordnernamen, den der Nutzer im Dialog
/// wählt — bei einem geklonten Fremd-Repo also von außen. Unquotiert genügte
/// ein Ordner `repo$(…)` für dauerhafte Codeausführung, und schon ein
/// Leerzeichen im Pfad hätte den Hook still zerbrochen.
fn todo_hook_command(dir: &std::path::Path) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
shell_quote(&dir.join(TODO_FILE).to_string_lossy())
)
}
/// Ein Argument für `sh -c` in einfache Anführungszeichen setzen. Innerhalb
/// davon ist jedes Zeichen literal; einzig das Apostroph selbst muss die
/// Quotierung verlassen und wieder betreten (`'\''`).
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn hook_is_todo(group: &serde_json::Value) -> bool {
group["hooks"]
.as_array()
.map(|hs| {
hs.iter().any(|h| {
h["command"]
.as_str()
.map_or(false, |c| c.contains(TODO_FILE))
})
})
.unwrap_or(false)
}
pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() {
return Ok(false);
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
Ok(
v["hooks"]["SessionStart"]
.as_array()
.map(|a| a.iter().any(hook_is_todo))
.unwrap_or(false),
)
}
pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?;
let dir = project_dir(paths, name)?;
let sp = settings_path(&dir);
if !sp.is_file() {
return Err(format!("settings.json fehlt: {}", sp.display()));
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let mut v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let hooks = root
.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("hooks ist kein Objekt")?;
let session_start = hooks
.entry("SessionStart")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("SessionStart ist kein Array")?;
session_start.retain(|g| !hook_is_todo(g));
if enabled {
session_start.insert(
0,
serde_json::json!({
"hooks": [
{ "type": "command", "command": todo_hook_command(&dir) }
]
}),
);
let todo_path = dir.join(TODO_FILE);
if !todo_path.is_file() {
fs::write(&todo_path, TODO_SKELETON)
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
}
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::project::{create_project_full_in, TerminalConfig};
use crate::domain::testutil::{create_project, tmp_paths};
/// Der Hook-Befehl geht durch die Shell; ein Pfad mit Metazeichen darf dort
/// nichts ausführen. Ordnernamen sind bei geklonten Repos Fremdeingabe.
#[test]
fn hook_befehl_quotet_den_pfad() {
let cmd = todo_hook_command(std::path::Path::new("/tmp/repo$(touch /tmp/pwned)"));
assert!(cmd.ends_with("'/tmp/repo$(touch /tmp/pwned)/OFFENE-PUNKTE.md'"), "{cmd}");
// Ein Apostroph im Pfad darf die Quotierung nicht aufbrechen.
let cmd = todo_hook_command(std::path::Path::new("/tmp/o'brien"));
assert!(cmd.ends_with(r"'/tmp/o'\''brien/OFFENE-PUNKTE.md'"), "{cmd}");
// Nach dem Zerlegen an den Quotes bleibt kein unquotierter Bereich übrig,
// in dem eine Shell noch etwas zu interpretieren hätte.
assert!(!cmd.contains("$("));
}
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
let id =
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
set_todo_in(&p, &id, true).unwrap();
assert!(todo_state_in(&p, &id).unwrap());
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
// doppelt aktivieren erzeugt keinen zweiten Hook
set_todo_in(&p, &id, true).unwrap();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
)
.unwrap();
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1);
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
set_todo_in(&p, &id, false).unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
assert!(todo_path.is_file());
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
}
#[test]
fn todo_ohne_settings_scheitert() {
let p = tmp_paths();
create_project(&p, "alt").unwrap();
assert!(set_todo_in(&p, "alt", true).is_err());
assert!(!todo_state_in(&p, "alt").unwrap());
}
}