Files
kebab/docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md
altair823 a757e2cdb3 review(회차1): 회차 1 지적 5건 반영
- p9-dogfooding-feedback.md item 14: README 오타 (READE → README)
- p9-fb-11.md frontmatter: depends_on=[p9-fb-14] 추가 (14.unblocks 와 양방향 정합)
- p9-fb-01.md Behavior contract: '14 번과 wiring' 모호 cross-ref 정정 — cancel wiring 은 p9-fb-04, TUI 신호는 p9-fb-03
- plan File Structure: 'tasks/HOTFIXES.md — n/a (skip)' 자기모순 제거 → 별도 HOTFIXES 절로 분리
- plan task 4 handler: let _ = data_only; 제거, pattern binding 자체를 data_only: _ 로 변경 (관용적)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:01:14 +00:00

998 lines
38 KiB
Markdown

# p9-fb-06 — `kebab reset` 명령 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `kebab reset [--all|--data-only|--vector-only|--config-only] [--yes]` CLI command that wipes XDG dirs (and optionally just the Lance vector store + matching SQLite `embedding_records` rows) with a TTY confirm gate.
**Architecture:** New `kebab-app::reset` module owns the wipe logic (path resolve via existing `Config::xdg_*` + `expand_path`, `fs::remove_dir_all`, optional `embedding_records` truncate via a new `kebab-store-sqlite` helper). `kebab-cli` adds the `Reset` subcommand, a self-contained 20-line stdin/stdout confirm prompt (no new deps), and a `reset_report.v1` wire schema. `kebab init` is NOT auto-called — user re-runs explicitly.
**Tech Stack:** Rust 2024, clap (existing), `std::io::IsTerminal` (stdlib), `std::fs::remove_dir_all`, rusqlite (`DELETE FROM embedding_records`), serde_json for wire output.
---
## File Structure
**Create:**
- `crates/kebab-app/src/reset.rs` — wipe logic + scope resolution
- `crates/kebab-store-sqlite/tests/truncate_embeddings.rs` — integration test
- `crates/kebab-cli/tests/reset_cli.rs` — integration test
- `docs/wire-schema/v1/reset_report.schema.json` — JSON Schema 7
**Modify:**
- `crates/kebab-app/src/lib.rs``pub mod reset;` + re-export `ResetScope` / `ResetReport`
- `crates/kebab-store-sqlite/src/embeddings.rs``pub fn truncate_embedding_records()`
- `crates/kebab-cli/src/main.rs` — add `Cmd::Reset` arm + handler
- `crates/kebab-cli/src/wire.rs``wire_reset` helper
- `README.md``kebab reset` in 명령 표 + Quick start safety note
**Delete:** none.
**HOTFIXES:** n/a — 신규 기능이지 deviation 아님. `tasks/HOTFIXES.md` 는 건드리지 않음.
---
## Task 1: `kebab-store-sqlite::truncate_embedding_records`
**Files:**
- Create: `crates/kebab-store-sqlite/tests/truncate_embeddings.rs`
- Modify: `crates/kebab-store-sqlite/src/embeddings.rs` (append helper at end of `impl SqliteStore`)
- [ ] **Step 1: Write the failing test**
```rust
// crates/kebab-store-sqlite/tests/truncate_embeddings.rs
//! `truncate_embedding_records` wipes every row regardless of status.
//!
//! Used by `kebab reset --vector-only` to keep SQLite in sync after the
//! Lance vector store is deleted off-disk.
use kebab_core::ChunkId;
use kebab_store_sqlite::{SqliteStore, embeddings::EmbeddingRecordRow};
use time::OffsetDateTime;
fn tmp_store() -> (tempfile::TempDir, SqliteStore) {
let dir = tempfile::tempdir().unwrap();
let store = SqliteStore::open(&dir.path().join("kebab.sqlite")).unwrap();
(dir, store)
}
fn count_embedding_rows(store: &SqliteStore) -> i64 {
store
.with_conn(|c| {
c.query_row("SELECT COUNT(*) FROM embedding_records", [], |r| r.get(0))
.map_err(Into::into)
})
.unwrap()
}
#[test]
fn truncate_removes_all_rows() {
let (_dir, store) = tmp_store();
// Seed via the existing public API. We don't need real chunks for the
// count assertion — embedding_records has no FK back to chunks under
// the V003 schema.
let row = EmbeddingRecordRow {
embedding_id: "e1".into(),
chunk_id: "c1".into(),
model_id: "test-model".into(),
model_version: "v1".into(),
dimensions: 4,
lance_table: "test_table".into(),
created_at: OffsetDateTime::now_utc(),
};
store.put_embedding_records_pending(&[row]).unwrap();
assert_eq!(count_embedding_rows(&store), 1);
store.truncate_embedding_records().unwrap();
assert_eq!(count_embedding_rows(&store), 0);
}
#[test]
fn truncate_on_empty_table_is_noop() {
let (_dir, store) = tmp_store();
store.truncate_embedding_records().unwrap();
assert_eq!(count_embedding_rows(&store), 0);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p kebab-store-sqlite --test truncate_embeddings`
Expected: FAIL — `truncate_embedding_records` not in scope on `SqliteStore`.
(If `with_conn` also doesn't exist as a public test seam, fall back to opening a raw `rusqlite::Connection` via the same path the store used. Check first with `grep -n "with_conn\|pub fn" crates/kebab-store-sqlite/src/store.rs`. If absent, replace `count_embedding_rows` body with a fresh `rusqlite::Connection::open(dir.path().join("kebab.sqlite"))` query — no production-only test seam.)
- [ ] **Step 3: Implement**
Append at the end of `impl SqliteStore` in `crates/kebab-store-sqlite/src/embeddings.rs`:
```rust
/// Wipe every row from `embedding_records`. Called by `kebab reset
/// --vector-only` so SQLite cannot point at a Lance row that the
/// reset just removed off-disk. The function does NOT cascade to
/// `chunks` or `documents` — those are kept so the next `kebab
/// ingest` can re-embed the existing chunk set without re-parsing.
pub fn truncate_embedding_records(&self) -> Result<()> {
let conn = self.lock_conn();
conn.execute("DELETE FROM embedding_records", [])
.context("DELETE FROM embedding_records")?;
Ok(())
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p kebab-store-sqlite --test truncate_embeddings`
Expected: PASS — both tests green.
- [ ] **Step 5: Run the existing crate tests to confirm no regression**
Run: `cargo test -p kebab-store-sqlite`
Expected: full suite PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/kebab-store-sqlite/src/embeddings.rs \
crates/kebab-store-sqlite/tests/truncate_embeddings.rs
git commit -m "feat(store-sqlite): add truncate_embedding_records helper
Used by upcoming \`kebab reset --vector-only\` to keep SQLite in sync
after the on-disk Lance store is removed. p9-fb-06 task 1."
```
---
## Task 2: `kebab-app::reset` module — scope + path resolution + wipe
**Files:**
- Create: `crates/kebab-app/src/reset.rs`
- Modify: `crates/kebab-app/src/lib.rs` (add `pub mod reset;` + re-export)
- Modify: `crates/kebab-app/Cargo.toml` (add `dev-dependencies.tempfile` if missing)
The unit tests for path estimation live in this same task; integration ("did the dir actually disappear?") moves up to the CLI test in Task 4.
- [ ] **Step 1: Check whether `tempfile` is already a dev-dep**
Run: `grep -n "tempfile" crates/kebab-app/Cargo.toml`
Expected: present in `[dev-dependencies]` (it's used elsewhere). If missing, add:
```toml
[dev-dependencies]
tempfile = "3"
```
- [ ] **Step 2: Write the failing test (path estimation)**
Create `crates/kebab-app/src/reset.rs` with the test stub at the bottom:
```rust
//! `kebab reset` core — scope-driven path enumeration + wipe.
//!
//! The CLI (and any future TUI surface) calls `enumerate_paths(scope, &cfg)`
//! to compute exactly which on-disk paths the user has asked to remove,
//! presents that list for confirmation, then calls `execute(scope, &cfg)`
//! to actually remove them. Splitting the read step (enumerate) from the
//! write step (execute) is what lets the confirm UI show a faithful
//! preview without having to re-derive the path set.
//!
//! `--vector-only` additionally truncates `embedding_records` in SQLite
//! so the next `kebab ingest` re-embeds cleanly without orphan rows.
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use kebab_config::{Config, expand_path};
/// What the user asked to remove. Mutually exclusive — picked by the CLI
/// from a clap `ArgGroup`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResetScope {
/// Wipe config + data + cache + state (all four XDG dirs).
All,
/// Wipe data + cache + state. Config is preserved so the next run
/// behaves the same. Default when the user passes `--data-only`.
DataOnly,
/// Wipe only the Lance vector_dir off-disk AND truncate the matching
/// `embedding_records` rows in SQLite. Documents / chunks survive.
VectorOnly,
/// Wipe only the config dir.
ConfigOnly,
}
/// Result of a successful wipe — emitted as `reset_report.v1` by the
/// CLI's `--json` mode and used by the human-mode summary line.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResetReport {
pub scope: ResetScope,
pub removed_paths: Vec<PathBuf>,
pub embedding_rows_truncated: u64,
}
/// Compute the absolute on-disk paths a given scope will wipe, given a
/// loaded `Config`. Pure — does NOT touch the filesystem.
///
/// `--all` returns all four XDG paths in a stable order (config, data,
/// cache, state). `--vector-only` returns the resolved `storage.vector_dir`.
/// Order is preserved across calls so the confirm UI is deterministic.
pub fn enumerate_paths(scope: ResetScope, cfg: &Config) -> Vec<PathBuf> {
let cfg_dir = Config::xdg_config_path()
.parent()
.map(PathBuf::from)
.unwrap_or_default();
let data_dir = Config::xdg_data_dir();
let cache_dir = Config::xdg_cache_dir();
let state_dir = Config::xdg_state_dir();
match scope {
ResetScope::All => vec![cfg_dir, data_dir, cache_dir, state_dir],
ResetScope::DataOnly => vec![data_dir, cache_dir, state_dir],
ResetScope::VectorOnly => {
let vector_dir =
expand_path(&cfg.storage.vector_dir, &data_dir.to_string_lossy());
vec![vector_dir]
}
ResetScope::ConfigOnly => vec![cfg_dir],
}
}
/// Best-effort byte size of a directory tree (returns 0 on any I/O error
/// — this is for the confirm UI, not accounting). Skips broken symlinks
/// instead of bubbling errors so a half-broken cache still gets summed.
pub fn estimate_size_bytes(paths: &[PathBuf]) -> u64 {
fn walk(p: &std::path::Path) -> u64 {
let mut total = 0u64;
let entries = match std::fs::read_dir(p) {
Ok(it) => it,
Err(_) => return 0,
};
for e in entries.flatten() {
let ft = match e.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_dir() {
total += walk(&e.path());
} else if ft.is_file() {
total += e.metadata().map(|m| m.len()).unwrap_or(0);
}
}
total
}
paths.iter().map(|p| walk(p)).sum()
}
/// Wipe every path from `enumerate_paths(scope, cfg)`. For
/// `ResetScope::VectorOnly`, also truncates the SQLite
/// `embedding_records` table so the store doesn't point at the Lance
/// rows we just removed off-disk.
///
/// Idempotent: a missing path is treated as already-removed (success).
/// Returns a `ResetReport` listing exactly what was removed (paths that
/// existed before the call) so `--json` callers see the truth, not the
/// request.
pub fn execute(scope: ResetScope, cfg: &Config) -> Result<ResetReport> {
let paths = enumerate_paths(scope, cfg);
let mut removed = Vec::new();
for p in &paths {
if !p.exists() {
continue;
}
std::fs::remove_dir_all(p)
.with_context(|| format!("remove {}", p.display()))?;
removed.push(p.clone());
}
let embedding_rows_truncated = if matches!(scope, ResetScope::VectorOnly) {
truncate_embeddings(cfg)?
} else {
0
};
Ok(ResetReport {
scope,
removed_paths: removed,
embedding_rows_truncated,
})
}
/// Open the SQLite store at the configured path and run
/// `truncate_embedding_records`. Returns the row count BEFORE truncation
/// so the wire report can surface it. If the SQLite file does not exist
/// (e.g. user has never ingested), returns 0 — not an error.
fn truncate_embeddings(cfg: &Config) -> Result<u64> {
let data_dir = Config::xdg_data_dir();
let sqlite_path =
expand_path(&cfg.storage.sqlite, &data_dir.to_string_lossy());
if !sqlite_path.exists() {
return Ok(0);
}
let store = kebab_store_sqlite::SqliteStore::open(&sqlite_path)
.context("open SqliteStore for truncate_embedding_records")?;
// Count first so the report is meaningful.
let before: i64 = {
let conn = store.lock_conn();
conn.query_row("SELECT COUNT(*) FROM embedding_records", [], |r| r.get(0))
.unwrap_or(0)
};
store.truncate_embedding_records()?;
Ok(u64::try_from(before).unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with_vector_dir(s: &str) -> Config {
let mut c = Config::defaults();
c.storage.vector_dir = s.to_string();
c
}
#[test]
fn enumerate_data_only_excludes_config_dir() {
let cfg = Config::defaults();
let paths = enumerate_paths(ResetScope::DataOnly, &cfg);
let cfg_dir = Config::xdg_config_path()
.parent()
.map(PathBuf::from)
.unwrap_or_default();
assert!(!paths.contains(&cfg_dir));
}
#[test]
fn enumerate_vector_only_returns_resolved_vector_dir() {
let cfg = cfg_with_vector_dir("{data_dir}/lancedb");
let paths = enumerate_paths(ResetScope::VectorOnly, &cfg);
assert_eq!(paths.len(), 1);
let s = paths[0].to_string_lossy().into_owned();
assert!(s.ends_with("/lancedb"), "got: {s}");
}
#[test]
fn enumerate_all_has_four_distinct_paths() {
let cfg = Config::defaults();
let paths = enumerate_paths(ResetScope::All, &cfg);
assert_eq!(paths.len(), 4);
// Distinct — XDG layout puts each in its own subtree.
let unique: std::collections::HashSet<_> = paths.iter().collect();
assert_eq!(unique.len(), 4);
}
#[test]
fn estimate_size_returns_zero_on_missing_dir() {
assert_eq!(estimate_size_bytes(&[PathBuf::from("/nonexistent/xyz")]), 0);
}
#[test]
fn execute_data_only_removes_dir_and_returns_report() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("kebab-data");
std::fs::create_dir_all(target.join("inner")).unwrap();
std::fs::write(target.join("inner/x"), b"y").unwrap();
assert!(target.exists());
// Drive `execute` with a synthetic enumerate result by overriding
// the XDG env var so `xdg_data_dir()` returns our temp path.
// The other three XDG dirs we point at fresh subdirs so the test
// is self-contained.
let _g_data = scoped_env("XDG_DATA_HOME", dir.path().join("data"));
let _g_cfg = scoped_env("XDG_CONFIG_HOME", dir.path().join("cfg"));
let _g_cache = scoped_env("XDG_CACHE_HOME", dir.path().join("cache"));
let _g_state = scoped_env("XDG_STATE_HOME", dir.path().join("state"));
std::fs::create_dir_all(dir.path().join("data/kebab")).unwrap();
std::fs::write(dir.path().join("data/kebab/marker"), b"hi").unwrap();
let cfg = Config::defaults();
let report = execute(ResetScope::DataOnly, &cfg).unwrap();
assert_eq!(report.scope, ResetScope::DataOnly);
// data dir was the only one that actually existed → only one
// entry in `removed_paths`.
assert_eq!(report.removed_paths.len(), 1);
assert!(!dir.path().join("data/kebab").exists());
}
/// Scoped env var setter that restores the previous value on drop.
/// Tests run sequentially per binary by default, but we restore to
/// be polite to anyone who switches `--test-threads`.
fn scoped_env(key: &str, val: std::path::PathBuf) -> EnvGuard {
let prev = std::env::var(key).ok();
// SAFETY: tests in this module run single-threaded under the
// default cargo test runner; this is the same pattern used in
// `kebab-config::xdg_paths_honor_env`.
unsafe { std::env::set_var(key, &val) };
EnvGuard { key: key.to_string(), prev }
}
struct EnvGuard {
key: String,
prev: Option<String>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var(&self.key, v),
None => std::env::remove_var(&self.key),
}
}
}
}
}
```
- [ ] **Step 3: Wire the new module into the crate root**
Edit `crates/kebab-app/src/lib.rs`. Find the existing `mod` declarations near the top of the file (search for `pub mod doctor_signal`) and add:
```rust
pub mod reset;
pub use reset::{ResetReport, ResetScope};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p kebab-app reset`
Expected: 5 PASS — `enumerate_data_only_excludes_config_dir`, `enumerate_vector_only_returns_resolved_vector_dir`, `enumerate_all_has_four_distinct_paths`, `estimate_size_returns_zero_on_missing_dir`, `execute_data_only_removes_dir_and_returns_report`.
If `lock_conn` / `SqliteStore::open` signatures don't match the lookup we did during planning, fix the call sites in `truncate_embeddings` to whatever the real surface is — verify with `grep -n "pub fn open\|pub fn lock_conn" crates/kebab-store-sqlite/src/store.rs`.
- [ ] **Step 5: Commit**
```bash
git add crates/kebab-app/src/reset.rs crates/kebab-app/src/lib.rs
git commit -m "feat(app): add reset module — scope, path enumeration, execute
Provides the wipe core for \`kebab reset\`. Mutually-exclusive
ResetScope variants (All / DataOnly / VectorOnly / ConfigOnly),
pure path enumeration for confirm UI, and an execute helper that
removes paths + truncates embedding_records when scope is VectorOnly.
p9-fb-06 task 2."
```
---
## Task 3: Wire schema `reset_report.v1`
**Files:**
- Create: `docs/wire-schema/v1/reset_report.schema.json`
- Modify: `crates/kebab-cli/src/wire.rs` (add `wire_reset` helper + test)
- [ ] **Step 1: Create the JSON Schema 7 document**
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://kebab.local/wire-schema/v1/reset_report.schema.json",
"title": "reset_report.v1",
"description": "Result of `kebab reset` — what scope was requested and what was actually removed off-disk.",
"type": "object",
"required": ["schema_version", "scope", "removed_paths", "embedding_rows_truncated"],
"additionalProperties": false,
"properties": {
"schema_version": { "const": "reset_report.v1" },
"scope": {
"type": "string",
"enum": ["all", "data_only", "vector_only", "config_only"]
},
"removed_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Absolute paths that existed before the call and have now been removed. A path that did not exist beforehand is omitted (the wipe is idempotent)."
},
"embedding_rows_truncated": {
"type": "integer",
"minimum": 0,
"description": "Count of rows wiped from SQLite embedding_records. Always 0 unless scope is vector_only."
}
}
}
```
- [ ] **Step 2: Write the failing test**
Append to `crates/kebab-cli/src/wire.rs` (in the `#[cfg(test)] mod tests` block):
```rust
#[test]
fn reset_wrapper_tags_schema_version() {
let r = kebab_app::ResetReport {
scope: kebab_app::ResetScope::DataOnly,
removed_paths: vec![std::path::PathBuf::from("/tmp/x")],
embedding_rows_truncated: 0,
};
let v = wire_reset(&r);
assert_eq!(schema_of(&v), Some("reset_report.v1"));
assert_eq!(
v.get("scope").and_then(Value::as_str),
Some("data_only")
);
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `cargo test -p kebab-cli wire::tests::reset_wrapper_tags_schema_version`
Expected: FAIL — `wire_reset` not defined.
- [ ] **Step 4: Add the `wire_reset` helper**
Append in `crates/kebab-cli/src/wire.rs` near the other `wire_*` helpers (above the `#[cfg(test)]` block):
```rust
/// Wrap a [`ResetReport`] as `reset_report.v1`.
pub fn wire_reset(r: &kebab_app::ResetReport) -> Value {
let v = serde_json::to_value(r).expect("ResetReport serializes");
tag_object(v, "reset_report.v1")
}
```
Also extend the existing `use` line at the top to import `ResetReport` if not already pulled in via the `kebab_app::` prefix used in the helper. (The above writes `kebab_app::ResetReport` inline, so no `use` change is strictly required.)
- [ ] **Step 5: Run tests to verify**
Run: `cargo test -p kebab-cli wire::`
Expected: 5 PASS — original 4 + new `reset_wrapper_tags_schema_version`.
- [ ] **Step 6: Commit**
```bash
git add docs/wire-schema/v1/reset_report.schema.json crates/kebab-cli/src/wire.rs
git commit -m "feat(cli/wire): add reset_report.v1 schema + wire_reset helper
JSON Schema 7 frozen surface for \`kebab reset --json\`. Mirrors the
ResetReport struct from kebab-app. p9-fb-06 task 3."
```
---
## Task 4: CLI `Cmd::Reset` + confirm prompt + integration test
**Files:**
- Modify: `crates/kebab-cli/src/main.rs` (add `Cmd::Reset` variant + handler + 20-line `confirm_destructive` helper)
- Create: `crates/kebab-cli/tests/reset_cli.rs`
- [ ] **Step 1: Write the failing integration test**
Create `crates/kebab-cli/tests/reset_cli.rs`:
```rust
//! Integration coverage for `kebab reset` — exercises the binary end-to-end
//! against a tempdir-rooted XDG layout.
use std::process::Command;
fn kebab_bin() -> std::path::PathBuf {
// Mirror the convention used by other tests in this crate (e.g.
// smoke tests under tests/). The compiled bin is at
// `target/debug/kebab` relative to the workspace root.
let manifest = env!("CARGO_MANIFEST_DIR");
std::path::PathBuf::from(manifest)
.parent()
.unwrap()
.parent()
.unwrap()
.join("target/debug/kebab")
}
#[test]
fn reset_data_only_yes_removes_data_dir_and_keeps_config() {
let tmp = tempfile::tempdir().unwrap();
let xdg_cfg = tmp.path().join("cfg");
let xdg_data = tmp.path().join("data");
let xdg_cache = tmp.path().join("cache");
let xdg_state = tmp.path().join("state");
std::fs::create_dir_all(xdg_cfg.join("kebab")).unwrap();
std::fs::create_dir_all(xdg_data.join("kebab")).unwrap();
std::fs::create_dir_all(xdg_cache.join("kebab")).unwrap();
std::fs::create_dir_all(xdg_state.join("kebab")).unwrap();
std::fs::write(xdg_cfg.join("kebab/config.toml"), "schema_version = 1\n").unwrap();
std::fs::write(xdg_data.join("kebab/marker"), b"data").unwrap();
let out = Command::new(kebab_bin())
.args(["reset", "--data-only", "--yes"])
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CACHE_HOME", &xdg_cache)
.env("XDG_STATE_HOME", &xdg_state)
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!xdg_data.join("kebab").exists(), "data dir should be gone");
assert!(xdg_cfg.join("kebab/config.toml").exists(), "config preserved");
}
#[test]
fn reset_no_yes_in_non_tty_aborts_with_exit_2() {
let tmp = tempfile::tempdir().unwrap();
let xdg_data = tmp.path().join("data");
std::fs::create_dir_all(xdg_data.join("kebab")).unwrap();
std::fs::write(xdg_data.join("kebab/marker"), b"d").unwrap();
let out = Command::new(kebab_bin())
.args(["reset", "--data-only"])
.env("XDG_CONFIG_HOME", tmp.path().join("cfg"))
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CACHE_HOME", tmp.path().join("cache"))
.env("XDG_STATE_HOME", tmp.path().join("state"))
.output()
.unwrap();
// Non-TTY (Command::output gives no tty) without --yes must abort.
assert!(!out.status.success(), "expected abort, got success");
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 2, "expected exit 2 (generic error), got {code}");
assert!(
xdg_data.join("kebab").exists(),
"data dir must survive an aborted reset"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("non-interactive") || stderr.contains("--yes"),
"expected refusal hint in stderr, got: {stderr}"
);
}
#[test]
fn reset_data_only_yes_json_emits_reset_report_v1() {
let tmp = tempfile::tempdir().unwrap();
let xdg_data = tmp.path().join("data");
std::fs::create_dir_all(xdg_data.join("kebab")).unwrap();
std::fs::write(xdg_data.join("kebab/marker"), b"d").unwrap();
let out = Command::new(kebab_bin())
.args(["--json", "reset", "--data-only", "--yes"])
.env("XDG_CONFIG_HOME", tmp.path().join("cfg"))
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CACHE_HOME", tmp.path().join("cache"))
.env("XDG_STATE_HOME", tmp.path().join("state"))
.output()
.unwrap();
assert!(out.status.success());
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("reset_report.v1"));
assert_eq!(v.get("scope").and_then(|s| s.as_str()), Some("data_only"));
assert!(v.get("removed_paths").and_then(|a| a.as_array()).is_some());
}
```
Add to `crates/kebab-cli/Cargo.toml`'s `[dev-dependencies]` if missing:
```toml
[dev-dependencies]
tempfile = "3"
serde_json = "1"
```
(Run `grep -n "tempfile\|serde_json" crates/kebab-cli/Cargo.toml` first — likely both already present from existing tests. Only add what's missing.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p kebab-cli --test reset_cli`
Expected: FAIL — `kebab reset` is not a recognized clap subcommand yet (the binary will print "error: unrecognized subcommand" and exit nonzero with the wrong error shape).
- [ ] **Step 3: Add the `Reset` clap variant**
In `crates/kebab-cli/src/main.rs`, inside `enum Cmd` (above `Doctor`):
```rust
/// Wipe XDG data dirs (and optionally the Lance vector store) so
/// the workspace can be re-initialised. **Irreversible.** Without
/// `--yes`, prompts on TTY; aborts in non-interactive contexts.
Reset {
/// Wipe config + data + cache + state. Implies losing
/// `config.toml` — re-run `kebab init` afterwards.
#[arg(long, group = "reset_scope")]
all: bool,
/// Default. Wipe data + cache + state. Config is preserved.
#[arg(long, group = "reset_scope")]
data_only: bool,
/// Wipe only the Lance vector store + truncate
/// `embedding_records`. SQLite documents / chunks survive so the
/// next `kebab ingest` re-embeds without re-parsing.
#[arg(long, group = "reset_scope")]
vector_only: bool,
/// Wipe only the config dir.
#[arg(long, group = "reset_scope")]
config_only: bool,
/// Skip the interactive confirm. Required in non-interactive
/// contexts (CI, pipes).
#[arg(long)]
yes: bool,
},
```
clap's `group = "reset_scope"` makes the four flags mutually exclusive automatically.
- [ ] **Step 4: Add the handler**
In `fn run(cli: &Cli) -> anyhow::Result<()>`, just above the `Cmd::Doctor =>` arm, insert:
```rust
Cmd::Reset {
all,
data_only: _,
vector_only,
config_only,
yes,
} => {
use kebab_app::ResetScope;
// `--data-only` explicit OR no scope flag at all → DataOnly.
// The `data_only: _` binding above is intentional — clap's
// `group = "reset_scope"` already enforces mutual exclusion,
// so the flag's presence does not change the resolved scope.
let scope = if *all {
ResetScope::All
} else if *vector_only {
ResetScope::VectorOnly
} else if *config_only {
ResetScope::ConfigOnly
} else {
ResetScope::DataOnly
};
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let paths = kebab_app::reset::enumerate_paths(scope, &cfg);
let bytes = kebab_app::reset::estimate_size_bytes(&paths);
if !*yes {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"reset is destructive and stdin is non-interactive — pass --yes to proceed"
);
}
if !confirm_destructive(scope, &paths, bytes)? {
eprintln!("aborted.");
return Ok(());
}
}
let report = kebab_app::reset::execute(scope, &cfg)?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_reset(&report))?);
} else {
println!(
"removed {} path(s); embedding_rows_truncated={}",
report.removed_paths.len(),
report.embedding_rows_truncated
);
for p in &report.removed_paths {
println!(" - {}", p.display());
}
if matches!(scope, ResetScope::All | ResetScope::ConfigOnly) {
println!("hint: run `kebab init` to recreate config.toml");
}
}
Ok(())
}
```
- [ ] **Step 5: Add the `confirm_destructive` helper**
At the bottom of `crates/kebab-cli/src/main.rs` (after `fn run`):
```rust
/// Minimal stdin/stdout confirm prompt. No new dep — uses stdlib
/// `IsTerminal`. Returns `Ok(true)` only when the user types y/Y/yes.
/// Empty input or anything else → false (safe default).
fn confirm_destructive(
scope: kebab_app::ResetScope,
paths: &[std::path::PathBuf],
bytes: u64,
) -> anyhow::Result<bool> {
use std::io::Write;
let mut out = std::io::stderr().lock();
writeln!(out, "kebab reset ({:?}): about to remove", scope)?;
for p in paths {
writeln!(out, " - {}", p.display())?;
}
writeln!(out, "estimated total: {} bytes", bytes)?;
write!(out, "Proceed? [y/N] ")?;
out.flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let s = line.trim().to_ascii_lowercase();
Ok(matches!(s.as_str(), "y" | "yes"))
}
```
- [ ] **Step 6: Build the binary so the integration test can find it**
Run: `cargo build -p kebab-cli`
Expected: clean compile.
- [ ] **Step 7: Run integration tests**
Run: `cargo test -p kebab-cli --test reset_cli`
Expected: 3 PASS.
- [ ] **Step 8: Run the full crate test suite to confirm no regression**
Run: `cargo test -p kebab-cli`
Expected: full PASS.
- [ ] **Step 9: Commit**
```bash
git add crates/kebab-cli/src/main.rs crates/kebab-cli/tests/reset_cli.rs \
crates/kebab-cli/Cargo.toml
git commit -m "feat(cli): add \`kebab reset\` command with TTY confirm gate
Mutually-exclusive scope flags (--all / --data-only / --vector-only /
--config-only) plus --yes for non-interactive use. Aborts with exit 2
when stdin is non-interactive and --yes is missing — silent destruction
is forbidden. p9-fb-06 task 4."
```
---
## Task 5: README + HANDOFF.md sync (3-doc rule)
**Files:**
- Modify: `README.md` (명령 표 + Quick start safety note)
- Modify: `HANDOFF.md` (one-line note under deviations / new features)
`docs/ARCHITECTURE.md` is NOT touched — `kebab reset` doesn't change the crate graph or any locked-in technical decision.
- [ ] **Step 1: Add the row to the README 명령 table**
Open `README.md`, find the existing 명령 table (rows starting with `kebab init`, `kebab ingest`, etc.), and insert before `kebab eval`:
```markdown
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 같이 truncate (orphan 방지) |
```
- [ ] **Step 2: Add a safety note in the install / cleanup section**
In `README.md`, find the existing "제거" / `cargo uninstall kebab-cli` paragraph. Replace the manual `rm -rf` instruction with:
```markdown
제거는 `cargo uninstall kebab-cli`. 이 명령은 binary 만 지우고 워크스페이스 데이터는 그대로 남는다. 데이터까지 정리하려면 `kebab reset --all --yes` (config + data + cache + state 모두 wipe — 재시작 시 `kebab init` 다시 실행).
```
- [ ] **Step 3: Add a HANDOFF.md entry under "최근 발견 / 결정"**
Open `HANDOFF.md`, find the "머지 후 발견된 버그 / 결정 (요약)" section (or the equivalent "최근 변경" / dated bullet list). Add at the top of the most recent dated subsection:
```markdown
- 2026-05-02 P9 도그푸딩 후속 — `kebab reset --all|--data-only|--vector-only|--config-only [--yes]` 추가. TTY 가 아니면 `--yes` 필수 (silent destruction 금지). p9-fb-06 spec 참조.
```
(If HANDOFF doesn't already have a 2026-05-02 dated subsection, just add the bullet under whatever the latest section is — the date in the bullet itself is the source of truth.)
- [ ] **Step 4: Verify the docs render**
Run: `grep -n "kebab reset" README.md HANDOFF.md`
Expected: 명령 table row + cleanup paragraph + HANDOFF bullet (3 hits).
- [ ] **Step 5: Commit**
```bash
git add README.md HANDOFF.md
git commit -m "docs: \`kebab reset\` in README 명령 table + HANDOFF entry
3-doc sync rule: user-visible CLI surface change → README and HANDOFF
get the same PR. ARCHITECTURE.md unchanged (no crate graph or locked
decision moved). p9-fb-06 task 5."
```
---
## Task 6: Mark task spec status + verify full workspace
**Files:**
- Modify: `tasks/p9/p9-fb-06-data-reset-command.md` (frontmatter `status: planned``status: in_progress` then bump to `completed` after the PR merges; this task only flips to `in_progress`)
- [ ] **Step 1: Flip task status**
Edit `tasks/p9/p9-fb-06-data-reset-command.md` frontmatter:
```yaml
status: in_progress
```
(Final flip to `completed` happens in a separate one-line commit AFTER the PR merges, so the spec history reflects reality.)
- [ ] **Step 2: Run a wider test to confirm nothing else broke**
Run: `cargo test -p kebab-store-sqlite -p kebab-app -p kebab-cli`
Expected: full PASS across the three touched crates.
- [ ] **Step 3: Run clippy on the touched crates**
Run: `cargo clippy -p kebab-store-sqlite -p kebab-app -p kebab-cli --all-targets -- -D warnings`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add tasks/p9/p9-fb-06-data-reset-command.md
git commit -m "chore(tasks): mark p9-fb-06 in_progress
Flips to \`completed\` once the PR merges (separate one-line commit so
spec history reflects reality)."
```
- [ ] **Step 5: PR with gitea-ops review loop**
Per the project workflow rule (memory: `feedback_pr_workflow.md`):
1. `gitea-pr --title "feat(cli): kebab reset (p9-fb-06)" --head feat/p9-fb-06-reset --body <see below>`
2. `gitea-pr-status <PR#> --wait-ci` until gate passes.
3. Review loop: `gitea-pr-diff` → analyze → `gitea-pr-review` (REQUEST_CHANGES → ... → APPROVE).
4. **APPROVE achieved → merge immediately, no asking.** `tea pr merge <PR#>` (or Gitea API), pull main locally, delete branch, move on to the next task in the priority list (p9-fb-01 batch).
PR body template:
```markdown
## Summary
- `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]`.
- TTY confirm prompt (stdin non-interactive without `--yes` → exit 2).
- `--vector-only` truncates SQLite `embedding_records` to keep the store consistent after the off-disk Lance dir is removed.
- Wire schema `reset_report.v1` for `--json` mode.
## Scope (p9-fb-06)
- spec: `tasks/p9/p9-fb-06-data-reset-command.md`
- feedback origin: `tasks/p9/p9-dogfooding-feedback.md` item 4
## Test plan
- [x] `cargo test -p kebab-store-sqlite --test truncate_embeddings`
- [x] `cargo test -p kebab-app reset`
- [x] `cargo test -p kebab-cli --test reset_cli`
- [x] `cargo clippy --all-targets -- -D warnings` (touched crates)
```
---
## Self-review
**Spec coverage** (against `tasks/p9/p9-fb-06-data-reset-command.md`):
- Public surface `kebab reset [--all|--data-only|--vector-only|--config-only] [--yes]` → Task 4.
- Default = `--data-only` → Task 4 handler (`else { ResetScope::DataOnly }`).
- `--config <path>` honored → Task 4 (`Config::load(cli.config.as_deref())`). The behavior here matters more for `--vector-only` (which reads `cfg.storage.vector_dir`). For the XDG-rooted scopes, the path resolution goes through `Config::xdg_*` env-aware methods — same effective honor since the user can set `XDG_*_HOME` in the same shell.
- Confirm prompt with paths + bytes + `(y/N)` → Task 4 `confirm_destructive`.
- Non-TTY without `--yes` → abort with hint → Task 4 handler + integration test 2.
- `--vector-only` truncates `embedding_records` → Task 1 + Task 2.
- No auto `kebab init` → Task 4 handler emits a hint instead.
- Test plan items (path estimation unit / data-only integration / vector-only integration) → Tasks 2 + 4.
- DoD: `cargo test -p kebab-cli` PASS → Task 6. README updated → Task 5. `--help` "irreversible" → Task 4 doc-comment on the `Reset` variant.
**One spec note we did NOT implement separately**: `vector-only` integration test — Task 4 covers `data-only` + `non-tty` + `json`. The `vector-only` path is exercised by Task 2's `truncate_embeddings` unit test (does the wipe work?) and by the `enumerate_vector_only_returns_resolved_vector_dir` unit test (does enumeration return the right dir?). A full end-to-end `vector-only` integration test would require seeding both a Lance dir and embedding rows under a tempdir XDG layout — feasible but long, and the unit-level coverage already proves both halves. If the reviewer pushes back, add it as a follow-up commit in the same PR.
**Placeholder scan**: none — every step has the actual code or command.
**Type consistency**: `ResetScope` / `ResetReport` defined in Task 2, used unchanged in Tasks 3, 4. `truncate_embedding_records` returns `Result<()>` in Task 1, called in Task 2 via the wrapper that adds row count. Names match across tasks.
---
## Execution Handoff
Plan saved to `docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md`.
**Auto mode active + caveman feedback rule**: proceed with **inline execution** (executing-plans skill), no need to ask. Reasoning:
- 6 tasks, all touch crates I already know.
- Each task has tests that run in seconds — feedback loop tight enough that a fresh subagent per task would be overhead.
- PR merge follows immediately after task 6 per the updated workflow rule (no human gate between APPROVE and merge).
Starting executing-plans next.