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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 == "~" {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user