fix: surface legacy data warning in instinct-cli status (#2127)

* fix: surface legacy data warning in instinct-cli status (#2036)

When the data directory moved from ~/.claude/homunculus/ to the
XDG-compliant ~/.local/share/ecc-homunculus/, legacy installs with data
still in the old path saw "No instincts found" with no explanation.

Add _warn_legacy_data() to cmd_status so users get a clear, actionable
warning pointing them to the migration script or the CLV2_HOMUNCULUS_DIR
override. Wrap the directory scan in try/except to handle permission
errors gracefully.

Closes #2036

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address review feedback — drop unused f-strings, resolve absolute migrate path

Remove extraneous f-prefix from strings without interpolation (ruff F541).
Resolve migrate-homunculus.sh path relative to instinct-cli.py instead of
hard-coding a repo-relative path that only works from the repo root.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: quote migrate script path to handle spaces

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: kky <lingmu141592@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xiaoxi
2026-06-07 13:26:22 +08:00
committed by GitHub
parent 3248ac69f0
commit e4dfc1679b
2 changed files with 96 additions and 0 deletions
@@ -703,10 +703,47 @@ def cmd_status(args) -> int:
days_left = max(0, PENDING_TTL_DAYS - item["age_days"]) days_left = max(0, PENDING_TTL_DAYS - item["age_days"])
print(f" - {item['name']} ({days_left}d remaining)") print(f" - {item['name']} ({days_left}d remaining)")
# Legacy data warning
_warn_legacy_data()
print(f"\n{'='*60}\n") print(f"\n{'='*60}\n")
return 0 return 0
def _warn_legacy_data() -> None:
"""Warn if legacy ~/.claude/homunculus/ contains data while the active
path has moved to the XDG directory."""
legacy_dir = Path.home() / ".claude" / "homunculus"
if legacy_dir == HOMUNCULUS_DIR:
return # CLV2_HOMUNCULUS_DIR explicitly points at the legacy path
if not legacy_dir.is_dir():
return
# Count substantive files (skip empty dirs and the directory itself)
try:
legacy_files = [f for f in legacy_dir.rglob("*") if f.is_file()]
except (PermissionError, OSError):
print(f"\n Note: legacy directory exists but cannot be read: {legacy_dir}", file=sys.stderr)
return
if not legacy_files:
return
migrate_script = Path(__file__).resolve().parent / "migrate-homunculus.sh"
print(f"\n{'!'*60}")
print(" LEGACY DATA DETECTED")
print(f"{'!'*60}")
print(f" Found {len(legacy_files)} file(s) in legacy path:")
print(f" {legacy_dir}")
print(" Active data directory:")
print(f" {HOMUNCULUS_DIR}")
print()
print(" Run the migration script to move your data:")
print(f' bash "{migrate_script}"')
print(f" Or set CLV2_HOMUNCULUS_DIR={legacy_dir} to use the legacy path.")
print(f"{'!'*60}\n")
def _print_instincts_by_domain(instincts: list[dict]) -> None: def _print_instincts_by_domain(instincts: list[dict]) -> None:
"""Helper to print instincts grouped by domain.""" """Helper to print instincts grouped by domain."""
by_domain = defaultdict(list) by_domain = defaultdict(list)
@@ -219,6 +219,65 @@ test('projects merge deduplicates instincts, appends observations, and removes s
} }
}); });
test('status warns when legacy ~/.claude/homunculus contains files', () => {
const root = createTempDir();
try {
const legacyDir = path.join(root, 'home', '.claude', 'homunculus', 'instincts', 'personal');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'old-instinct.yaml'), '---\nid: old\n---\nOld instinct.\n');
const result = runCli(root, ['status']);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /LEGACY DATA DETECTED/);
assert.match(result.stdout, /legacy path/i);
assert.match(result.stdout, /migration script/i);
} finally {
cleanupDir(root);
}
});
test('status does not warn when legacy dir is empty', () => {
const root = createTempDir();
try {
const legacyDir = path.join(root, 'home', '.claude', 'homunculus');
fs.mkdirSync(legacyDir, { recursive: true });
const result = runCli(root, ['status']);
assert.strictEqual(result.status, 0, result.stderr);
assert.doesNotMatch(result.stdout, /LEGACY DATA DETECTED/);
} finally {
cleanupDir(root);
}
});
test('status does not warn when no legacy dir exists', () => {
const root = createTempDir();
try {
const result = runCli(root, ['status']);
assert.strictEqual(result.status, 0, result.stderr);
assert.doesNotMatch(result.stdout, /LEGACY DATA DETECTED/);
} finally {
cleanupDir(root);
}
});
test('status does not warn when CLV2_HOMUNCULUS_DIR points at legacy path', () => {
const root = createTempDir();
try {
const legacyDir = path.join(root, 'home', '.claude', 'homunculus', 'instincts', 'personal');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'active.yaml'), '---\nid: active\n---\nActive.\n');
const result = runCli(root, ['status'], {
env: { CLV2_HOMUNCULUS_DIR: path.join(root, 'home', '.claude', 'homunculus') },
});
assert.strictEqual(result.status, 0, result.stderr);
assert.doesNotMatch(result.stdout, /LEGACY DATA DETECTED/);
} finally {
cleanupDir(root);
}
});
test('status migrates legacy no-remote linked worktree project dirs to main worktree id', () => { test('status migrates legacy no-remote linked worktree project dirs to main worktree id', () => {
const root = createTempDir(); const root = createTempDir();
const repoParent = createTempDir(); const repoParent = createTempDir();