From 8e6867025e127f77f3f86f000e41118397550b54 Mon Sep 17 00:00:00 2001 From: growlf Date: Mon, 12 Oct 2026 21:48:51 -0700 Subject: [PATCH 1/4] 440: tests for config-driven project list + recursive discovery --- tests/test_cache.py | 38 ++++++++++ tests/test_opskit_projects.py | 139 ++++++++++++++++++++++++++++++++++ tests/test_tui_matching.py | 24 ++++++ 3 files changed, 201 insertions(+) diff --git a/tests/test_cache.py b/tests/test_cache.py index d8334fc..e461362 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -41,6 +41,7 @@ def cache_dir(tmp_path, monkeypatch): monkeypatch.setattr("opskit._cache.CACHE_DIR", test_cache) monkeypatch.setattr("opskit._cache.LAST_DISCOVERY_FILE", test_cache / "last-discovery.json") monkeypatch.setattr("opskit._cache.LAST_USED_FILE", test_cache / "last-used.json") + monkeypatch.setattr("opskit._cache.CONFIG_FILE", test_cache / "config.yml") return test_cache @@ -219,6 +220,43 @@ def test_get_sort_key(self, cache_dir): assert get_sort_key("/tmp/nonexistent") == "" +class TestConfig: + """Test ~/.opskit/config.yml project allow-list handling.""" + + def test_default_projects_empty(self, cache_dir): + from opskit import _cache + assert _cache.read_config().get("projects") == [] + assert _cache.get_projects() == [] + + def test_projects_round_trip(self, cache_dir): + from opskit import _cache + _cache.write_config({"projects_dir": "/tmp/p", "projects": ["A", "lfnw/x"]}) + assert _cache.get_projects() == ["A", "lfnw/x"] + + def test_null_projects_normalised_to_empty(self, cache_dir): + from opskit import _cache + (cache_dir / "config.yml").write_text("projects_dir: /tmp/p\nprojects:\n") + assert _cache.get_projects() == [] + + def test_cache_stale_when_config_newer(self, cache_dir, tmp_path): + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + data = { + "version": 1, + "timestamp": datetime.now(timezone.utc).isoformat(), + "projects_dir": str(projects_dir), + "projects_dir_mtime": projects_dir.stat().st_mtime, + "projects": [], + } + write_discovery_cache(data) + assert cache_is_stale(str(projects_dir)) is False + cfg = cache_dir / "config.yml" + cfg.write_text("projects_dir: /tmp/x\n") + future = time.time() + 10 + os.utime(cfg, (future, future)) + assert cache_is_stale(str(projects_dir)) is True + + class TestGetProjectFromCache: """Test project lookup by path.""" diff --git a/tests/test_opskit_projects.py b/tests/test_opskit_projects.py index e3c698f..591b16e 100644 --- a/tests/test_opskit_projects.py +++ b/tests/test_opskit_projects.py @@ -75,3 +75,142 @@ def test_list_without_cache_is_graceful(self, sandbox): result = _run(["list"], sandbox["env"]) assert result.returncode == 0, result.stderr assert "No cached projects" in result.stdout + + +def _write_config(home, projects_dir, projects=None): + d = home / ".opskit" + d.mkdir(parents=True, exist_ok=True) + lines = [f"projects_dir: {projects_dir}"] + if projects is not None: + lines.append("projects:") + for p in projects: + lines.append(f" - {p}") + (d / "config.yml").write_text("\n".join(lines) + "\n") + + +def _env(home): + e = dict(os.environ) + e["HOME"] = str(home) + return e + + +def _cache(home): + return json.loads((home / ".opskit" / "last-discovery.json").read_text()) + + +def _cfg(home): + import yaml + return yaml.safe_load((home / ".opskit" / "config.yml").read_text()) or {} + + +@pytest.fixture +def tree(tmp_path): + home = tmp_path / "home" + home.mkdir() + projects = tmp_path / "projects" + projects.mkdir() + return {"home": home, "projects": projects, "env": _env(home)} + + +class TestRecursiveDiscovery: + def test_finds_nested_project_through_grouping_dir(self, tree): + (tree["projects"] / "group" / "real" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"]) + result = _run(["rescan"], tree["env"]) + assert result.returncode == 0, result.stderr + assert "Found 1 project" in result.stdout + proj = _cache(tree["home"])["projects"][0] + assert proj["name"] == "real" + assert proj["rel"] == "group/real" + + def test_excludes_git_worktrees(self, tree): + (tree["projects"] / "Foo" / ".git").mkdir(parents=True) + wt = tree["projects"] / "Foo-wt-x" + wt.mkdir() + (wt / ".git").write_text("gitdir: /somewhere\n") + _write_config(tree["home"], tree["projects"]) + _run(["rescan"], tree["env"]) + names = [p["name"] for p in _cache(tree["home"])["projects"]] + assert names == ["Foo"] + + def test_does_not_descend_into_a_project(self, tree): + proj = tree["projects"] / "Outer" + (proj / ".git").mkdir(parents=True) + (proj / "inner" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"]) + _run(["rescan"], tree["env"]) + rels = [p["rel"] for p in _cache(tree["home"])["projects"]] + assert rels == ["Outer"] + + def test_scan_records_no_git_fields(self, tree): + (tree["projects"] / "Demo" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"]) + _run(["rescan"], tree["env"]) + proj = _cache(tree["home"])["projects"][0] + assert "git_branch" not in proj + assert "git_status" not in proj + + +class TestEnabledFilter: + def test_keeps_only_listed_projects(self, tree): + (tree["projects"] / "Demo" / ".git").mkdir(parents=True) + (tree["projects"] / "Other" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=["Demo"]) + result = _run(["rescan"], tree["env"]) + assert "Found 1 project" in result.stdout + assert [p["name"] for p in _cache(tree["home"])["projects"]] == ["Demo"] + + def test_selector_matches_nested_rel_path(self, tree): + (tree["projects"] / "lfnw" / "lfnw-django" / ".git").mkdir(parents=True) + (tree["projects"] / "Other" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=["lfnw/lfnw-django"]) + _run(["rescan"], tree["env"]) + assert [p["rel"] for p in _cache(tree["home"])["projects"]] == ["lfnw/lfnw-django"] + + def test_unmatched_selector_warns(self, tree): + (tree["projects"] / "Demo" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=["Nope"]) + result = _run(["rescan"], tree["env"]) + assert "Nope" in result.stderr + + def test_empty_list_keeps_all_candidates(self, tree): + (tree["projects"] / "Demo" / ".git").mkdir(parents=True) + (tree["projects"] / "Other" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=[]) + _run(["rescan"], tree["env"]) + assert len(_cache(tree["home"])["projects"]) == 2 + + +class TestBootstrap: + def test_enables_pack_yml_members_only(self, tree): + member = tree["projects"] / "Member" + (member / ".opskit").mkdir(parents=True) + (member / ".opskit" / "pack.yml").write_text("contract: 1\nname: member\n") + (tree["projects"] / "JustGit" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=[]) + result = _run(["bootstrap"], tree["env"]) + assert result.returncode == 0, result.stderr + assert _cfg(tree["home"])["projects"] == ["Member"] + assert [p["name"] for p in _cache(tree["home"])["projects"]] == ["member"] + + +class TestConfigManagedList: + def test_add_appends_selector_and_rescans(self, tree): + (tree["projects"] / "Demo" / ".git").mkdir(parents=True) + (tree["projects"] / "Other" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=["Demo"]) + result = _run(["add", str(tree["projects"] / "Other")], tree["env"]) + assert result.returncode == 0, result.stderr + assert "Other" in _cfg(tree["home"])["projects"] + names = {p["name"] for p in _cache(tree["home"])["projects"]} + assert names == {"Demo", "Other"} + + def test_remove_drops_selector(self, tree): + (tree["projects"] / "Demo" / ".git").mkdir(parents=True) + (tree["projects"] / "Other" / ".git").mkdir(parents=True) + _write_config(tree["home"], tree["projects"], projects=["Demo", "Other"]) + _run(["rescan"], tree["env"]) + result = _run(["remove", "Other"], tree["env"]) + assert result.returncode == 0, result.stderr + assert _cfg(tree["home"])["projects"] == ["Demo"] + assert [p["name"] for p in _cache(tree["home"])["projects"]] == ["Demo"] diff --git a/tests/test_tui_matching.py b/tests/test_tui_matching.py index a311fed..b43d7aa 100644 --- a/tests/test_tui_matching.py +++ b/tests/test_tui_matching.py @@ -75,3 +75,27 @@ def test_case_insensitive(self): def test_empty_projects_list(self): matches = fuzzy_match_projects("anything", []) assert len(matches) == 0 + + +class TestFuzzySubsequence: + """Subsequence matching over name / basename / nested rel path.""" + + def test_noncontiguous_subsequence(self): + projects = [{"name": "lfnw-django", "path": "/p/lfnw/lfnw-django"}] + assert len(fuzzy_match_projects("lfdj", projects)) == 1 + + def test_matches_nested_rel_path(self): + projects = [ + {"name": "lfnw-django", "path": "/p/lfnw/lfnw-django", "rel": "lfnw/lfnw-django"} + ] + assert len(fuzzy_match_projects("lfnw", projects)) == 1 + + def test_matches_nested_query_form(self): + projects = [ + {"name": "lfnw-django", "path": "/p/lfnw/lfnw-django", "rel": "lfnw/lfnw-django"} + ] + assert len(fuzzy_match_projects("lfnw/lfnw", projects)) == 1 + + def test_rejects_non_subsequence(self): + projects = [{"name": "Phoenix", "path": "/p/Phoenix"}] + assert len(fuzzy_match_projects("xyz", projects)) == 0 From 7dce919fa45e0a07eee0dc46200ca150a328573d Mon Sep 17 00:00:00 2001 From: growlf Date: Mon, 12 Oct 2026 21:55:45 -0700 Subject: [PATCH 2/4] 440: config-driven enabled list, recursive discovery, lazier launcher --- bin/opskit-go.py | 147 ++++++++++++++--- bin/opskit-projects.py | 362 +++++++++++++++++++++++++++++------------ opskit/_cache.py | 15 ++ 3 files changed, 396 insertions(+), 128 deletions(-) diff --git a/bin/opskit-go.py b/bin/opskit-go.py index e7a6a3c..aec1ffe 100644 --- a/bin/opskit-go.py +++ b/bin/opskit-go.py @@ -26,6 +26,7 @@ """ import argparse +import difflib import os import subprocess import sys @@ -36,11 +37,13 @@ sys.path.insert(0, str(_OPSKIT_ROOT)) from opskit._cache import ( + CONFIG_FILE, read_discovery_projects, cache_is_stale, get_last_used_order, update_last_used, get_projects_dir, + get_projects, ) @@ -54,38 +57,127 @@ def ensure_cache(): ) +def ensure_enabled(): + """Guarantee a non-empty enabled list; offer to bootstrap on first run.""" + if get_projects(): + return True + + if not sys.stdin.isatty(): + print( + "No projects enabled. Run 'opskit projects bootstrap' or " + "'opskit projects add '.", + file=sys.stderr, + ) + return False + + print(f"No projects enabled in {CONFIG_FILE}.") + try: + answer = input( + f"Scan {get_projects_dir()} for OpsKit projects (.opskit/pack.yml) " + "and enable them? [y/N] " + ) + except EOFError: + return False + + if answer.strip().lower() not in ("y", "yes"): + print("Nothing enabled. Add projects with 'opskit projects add '.") + return False + + subprocess.run( + [sys.executable, str(SCRIPT_DIR / "opskit-projects.py"), "bootstrap"], + check=False, + ) + return bool(get_projects()) + + def sort_projects_by_last_used(projects): """Sort projects by last-used timestamp (most recent first).""" project_map = {p["path"]: p for p in projects} - last_used_order = get_last_used_order() - # Projects in last-used order first, then uncached projects sorted_projects = [] - for path in last_used_order: - if path in project_map: + seen = set() + for path in get_last_used_order(): + if path in project_map and path not in seen: sorted_projects.append(project_map[path]) + seen.add(path) - # Add any projects not in last-used + # Append projects not present in the last-used cache. for proj in projects: - if proj["path"] not in project_map or proj not in sorted_projects: + if proj["path"] not in seen: sorted_projects.append(proj) + seen.add(proj["path"]) return sorted_projects +def _subsequence_score(query, target): + """Score query as a subsequence of target, or None if it isn't one. + + Rewards consecutive runs and word-boundary hits (fzf-style) so that + 'lfnw' prefers 'lfnw-django' over an incidental scatter of letters. + """ + qi = 0 + score = 0 + last = -2 + for i, ch in enumerate(target): + if qi < len(query) and ch == query[qi]: + score += 1 + if last == i - 1: + score += 2 + if i == 0 or target[i - 1] in "/-_. ": + score += 3 + last = i + qi += 1 + if qi == len(query): + return score + return None + + +def _project_targets(proj): + return [ + proj.get("name", "").lower(), + Path(proj.get("path", "")).name.lower(), + (proj.get("rel", "") or "").lower(), + ] + + def fuzzy_match_projects(name, projects): - """Fuzzy match a project name against the project list. + """Fuzzy (subsequence) match a query against name / basename / rel path. - Returns a list of matching project dicts. + Returns matching project dicts, best score first. """ - name_lower = name.lower() - matches = [] + query = name.strip().lower() + if not query: + return [] + + scored = [] for proj in projects: - proj_name = proj.get("name", "").lower() - proj_path = proj.get("path", "").lower() - if name_lower in proj_name or name_lower in proj_path: - matches.append(proj) - return matches + best = None + for target in _project_targets(proj): + s = _subsequence_score(query, target) + if s is not None and (best is None or s > best): + best = s + if best is not None: + scored.append((best, proj)) + + scored.sort(key=lambda item: item[0], reverse=True) + return [proj for _, proj in scored] + + +def _git_info(path): + """(branch, status) computed on demand — the scan never shells out to git.""" + try: + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=path, capture_output=True, text=True, timeout=10, + ).stdout.strip() or "detached" + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=path, capture_output=True, text=True, timeout=10, + ) + return branch, ("dirty" if status.stdout.strip() else "clean") + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return "unknown", "unknown" def display_project_info(proj): @@ -99,7 +191,8 @@ def display_project_info(proj): if proj.get("description"): lines.append(f"Description: {proj['description']}") lines.append(f"Classification: {proj.get('classification', 'unknown')}") - lines.append(f"Git: {proj.get('git_branch', '?')} ({proj.get('git_status', '?')})") + branch, status = _git_info(proj.get("path", "")) + lines.append(f"Git: {branch} ({status})") if proj.get("has_resume"): lines.append("Resume: has RESUME.md") @@ -167,7 +260,7 @@ def run_fzf_selection(projects): # Build fzf input: tab-separated name and path, sorted by last-used lines = [] for proj in projects: - name = proj.get("name", "unknown") + name = proj.get("rel") or proj.get("name", "unknown") path = proj.get("path", "") # Use path as the selection value lines.append(f"{name}\t{path}") @@ -176,7 +269,7 @@ def run_fzf_selection(projects): try: fzf_proc = subprocess.run( - ["fzf"], + ["fzf", "--height=40%", "--reverse", "--prompt=opskit> "], input=input_data, capture_output=True, text=True, timeout=60, ) @@ -204,11 +297,15 @@ def main(): args = parser.parse_args() + # First run with no enabled projects: offer to scan for OpsKit members. + if not ensure_enabled(): + sys.exit(0) + # Cache-first: rescan only when the cache is missing or stale. ensure_cache() projects = read_discovery_projects() if not projects: - print("No projects cached. Run: opskit projects rescan") + print("No projects found. Run: opskit projects rescan") sys.exit(1) # Sort by last-used @@ -220,7 +317,9 @@ def main(): for proj in projects: proj_name = proj.get("name", "") proj_path = proj.get("path", "") - if proj_name == args.project or proj_path == args.project or proj_path.endswith(args.project): + proj_rel = proj.get("rel", "") + if (proj_name == args.project or proj_rel == args.project + or proj_path == args.project or proj_path.endswith(args.project)): exact_match = proj break @@ -243,9 +342,13 @@ def main(): sys.exit(0) else: print(f"No project matches '{args.project}'", file=sys.stderr) + names = [p.get("name", "") for p in projects] + close = difflib.get_close_matches(args.project, names, n=5, cutoff=0.3) + if not close: + close = names[:5] print("Suggestions:", file=sys.stderr) - for proj in projects[:5]: - print(f" {proj.get('name', 'unknown')}", file=sys.stderr) + for suggestion in close: + print(f" {suggestion}", file=sys.stderr) sys.exit(1) else: # No project specified — show TUI diff --git a/bin/opskit-projects.py b/bin/opskit-projects.py index 40accb0..9372dee 100755 --- a/bin/opskit-projects.py +++ b/bin/opskit-projects.py @@ -4,10 +4,16 @@ Subcommands: rescan Force full rescan of the configured projects_dir - add Add a single project to the cache without rescanning - list Show cached projects + bootstrap Enable every OpsKit member (.opskit/pack.yml) found under projects_dir + add Enable a project and refresh the cache + remove Disable a project (drop it from the enabled list) + list Show cached (enabled) projects config Show or set the projects_dir path +The set of projects offered by `opskit go`/`web` is the `projects:` allow-list in +~/.opskit/config.yml. When that list is empty, discovery falls back to every +candidate found under projects_dir (so `rescan` still reports what exists). + Reads/writes ~/.opskit/last-discovery.json and ~/.opskit/config.yml. Uses opskit._cache for atomic cache operations. """ @@ -35,47 +41,81 @@ # ── Scan helpers ───────────────────────────────────────────────────────────── -def scan_project_dir(projects_dir: str): - """Scan a directory for OpsKit-enabled projects. +# Directories never worth descending into (VCS internals, caches, build output). +PRUNE_DIRS = { + ".git", "node_modules", ".venv", "venv", "__pycache__", "target", + "dist", "build", ".tox", ".mypy_cache", ".pytest_cache", ".next", + "coverage", "htmlcov", ".idea", ".vscode", +} +MAX_DEPTH = 4 + + +def _is_worktree(entry: Path) -> bool: + """A linked git worktree (.git is a file) or a *-wt-* scratch directory.""" + if (entry / ".git").is_file(): + return True + return "-wt-" in entry.name - Returns a list of project dicts with name, path, description, classification, - has_resume, git_branch, git_status. + +def discover_projects(projects_dir: str, members_only: bool = False): + """Recursively discover projects under projects_dir. + + A directory is a project if it contains .git/ or .opskit/pack.yml. Grouping + directories (neither) are descended into, bounded by MAX_DEPTH. Git worktrees + are skipped by default. When members_only is set, only OpsKit members + (.opskit/pack.yml) are returned. """ - projects_dir_path = Path(projects_dir).resolve() - if not projects_dir_path.exists(): + root = Path(projects_dir).resolve() + if not root.exists(): print(f"Projects directory does not exist: {projects_dir}", file=sys.stderr) return [] projects = [] - for entry in sorted(projects_dir_path.iterdir()): - # Skip dotdirs and non-directories - if entry.name.startswith("."): - continue - if not entry.is_dir(): - continue - - # Must contain .git/ or pack.yml to be considered a project - git_dir = entry / ".git" - pack_file = entry / ".opskit" / "pack.yml" - - if not git_dir.exists() and not pack_file.exists(): - continue - - project = _analyze_project(entry, git_dir, pack_file) - if project: - projects.append(project) + def walk(directory: Path, depth: int): + if depth > MAX_DEPTH: + return + try: + entries = sorted(directory.iterdir()) + except OSError: + return + for entry in entries: + if entry.name.startswith(".") or entry.name in PRUNE_DIRS: + continue + if not entry.is_dir() or entry.is_symlink(): + continue + + git_dir = entry / ".git" + pack_file = entry / ".opskit" / "pack.yml" + has_git = git_dir.exists() + has_pack = pack_file.exists() + + if has_git or has_pack: + if _is_worktree(entry): + continue + if members_only and not has_pack: + continue + project = _analyze_project(entry, pack_file, root) + if project: + projects.append(project) + # Do not descend into a matched project. + else: + walk(entry, depth + 1) + + walk(root, 1) return projects -def _analyze_project(entry: Path, git_dir: Path, pack_file: Path): - """Analyze a single project directory. +def scan_project_dir(projects_dir: str): + """Backwards-compatible full discovery (all candidates).""" + return discover_projects(projects_dir) - Returns a project dict or None if the project should be skipped. - """ - # Get name from pack.yml or use directory name + +def _analyze_project(entry: Path, pack_file: Path, root: Path): + """Build a project record. No git calls here — that is done lazily.""" description = "" classification = "public" + name = entry.name if pack_file.exists(): try: @@ -86,45 +126,71 @@ def _analyze_project(entry: Path, git_dir: Path, pack_file: Path): classification = pack_data.get("data_classification", "public") except Exception: name = entry.name - else: - name = entry.name - # Get git info - git_branch = "" - git_status = "" - if git_dir.exists(): - try: - branch_result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=str(entry), capture_output=True, text=True, timeout=10, - ) - git_branch = branch_result.stdout.strip() or "detached" - - status_result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=str(entry), capture_output=True, text=True, timeout=10, - ) - git_status = "dirty" if status_result.stdout.strip() else "clean" - except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - git_branch = "unknown" - git_status = "unknown" - - # Check for RESUME.md - has_resume = (entry / "RESUME.md").exists() - - canonical_path = str(entry.resolve()) + try: + rel = entry.relative_to(root).as_posix() + except ValueError: + rel = entry.name return { "name": name, - "path": canonical_path, + "path": str(entry.resolve()), + "rel": rel, "description": description, "classification": classification, - "has_resume": has_resume, - "git_branch": git_branch, - "git_status": git_status, + "has_resume": (entry / "RESUME.md").exists(), } +def git_info(path: str): + """Return (branch, status) for a project, computed on demand.""" + try: + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=path, capture_output=True, text=True, timeout=10, + ).stdout.strip() or "detached" + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=path, capture_output=True, text=True, timeout=10, + ) + return branch, ("dirty" if status.stdout.strip() else "clean") + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return "unknown", "unknown" + + +# ── Enabled-list matching ──────────────────────────────────────────────────── + +def _selector_key(value: str) -> str: + return value.strip().rstrip("/").lower().lstrip("./") + + +def project_matches_selector(proj: dict, selector: str) -> bool: + """True if a selector (rel path, basename, name, or abs path) names proj.""" + s = _selector_key(selector) + if not s: + return False + candidates = { + _selector_key(proj.get("rel", "")), + Path(proj.get("path", "")).name.lower(), + proj.get("name", "").lower(), + proj.get("path", "").lower(), + } + return s in candidates + + +def filter_enabled(projects, selectors): + """Return (kept, unmatched) for the configured selectors.""" + kept, unmatched = [], [] + for sel in selectors: + matched = [p for p in projects if project_matches_selector(p, sel)] + if not matched: + unmatched.append(sel) + for m in matched: + if m not in kept: + kept.append(m) + return kept, unmatched + + def _projects_dir_mtime(projects_dir: str): """mtime of projects_dir (float), or None if it can't be stat'd.""" try: @@ -143,64 +209,136 @@ def _build_cache(projects_dir: str, projects: list) -> dict: } +def _run_rescan(projects_dir: str): + """Discover, apply the enabled filter, write the cache. Returns the list.""" + candidates = discover_projects(projects_dir) + selectors = read_config().get("projects") or [] + if selectors: + projects, unmatched = filter_enabled(candidates, selectors) + for sel in unmatched: + print(f"Warning: configured project not found: {sel}", file=sys.stderr) + else: + projects = candidates + write_discovery_cache(_build_cache(projects_dir, projects)) + return projects + + # ── Subcommands ────────────────────────────────────────────────────────────── def cmd_rescan(args): """Force full rescan of the configured projects_dir.""" projects_dir = get_projects_dir() print(f"Scanning projects in: {projects_dir}") - - projects = scan_project_dir(projects_dir) - write_discovery_cache(_build_cache(projects_dir, projects)) - + projects = _run_rescan(projects_dir) print(f"Found {len(projects)} project(s)") for proj in projects: status = "[resume]" if proj.get("has_resume") else "" - print(f" {proj['name']:30s} {proj.get('git_branch', ''):15s} {status}") + print(f" {proj['rel']:30s} {status}") + + +def cmd_bootstrap(args): + """Enable every OpsKit member (.opskit/pack.yml) found under projects_dir.""" + projects_dir = get_projects_dir() + members = discover_projects(projects_dir, members_only=True) + + config = read_config() + selectors = list(config.get("projects") or []) + for proj in members: + if proj["rel"] not in selectors: + selectors.append(proj["rel"]) + config["projects"] = selectors + write_config(config) + + if members: + print(f"Enabled {len(members)} OpsKit project(s):") + for proj in members: + print(f" {proj['rel']}") + else: + print("No OpsKit projects (.opskit/pack.yml) found.") + + projects = _run_rescan(projects_dir) + print(f"Found {len(projects)} project(s)") def cmd_add(args): - """Add a single project to the cache.""" - project_path = Path(args.path).resolve() + """Enable a single project and refresh the cache.""" + projects_dir = get_projects_dir() + projects_root = Path(projects_dir).resolve() + + candidate = Path(args.path) + if not candidate.is_absolute(): + candidate = projects_root / args.path + project_path = candidate.resolve() + if not project_path.exists(): - print(f"Path does not exist: {args.path}", file=sys.stderr) + matches = [ + p for p in discover_projects(projects_dir) + if p["name"].lower() == args.path.lower() + or Path(p["path"]).name.lower() == args.path.lower() + ] + if len(matches) == 1: + project_path = Path(matches[0]["path"]) + else: + print(f"Path not found: {args.path}", file=sys.stderr) + sys.exit(1) + + has_git = (project_path / ".git").exists() + has_pack = (project_path / ".opskit" / "pack.yml").exists() + if not has_git and not has_pack: + print( + f"Not an OpsKit project (no .git/ or .opskit/pack.yml): {project_path}", + file=sys.stderr, + ) sys.exit(1) - projects_dir = get_projects_dir() - if not project_path.is_relative_to(Path(projects_dir).resolve()): + if project_path.is_relative_to(projects_root): + selector = project_path.relative_to(projects_root).as_posix() + else: print(f"Warning: path is outside projects_dir ({projects_dir})", file=sys.stderr) + selector = str(project_path) - # Scan the single project - pack_file = project_path / ".opskit" / "pack.yml" - git_dir = project_path / ".git" - - projects = scan_project_dir(str(projects_dir)) - existing_paths = {p["path"] for p in projects} - - # Add the new project if not already present - # Re-analyze it - new_proj = _analyze_project(project_path, git_dir, pack_file) - if new_proj and new_proj["path"] not in existing_paths: - projects.append(new_proj) - write_discovery_cache(_build_cache(projects_dir, projects)) - print(f"Added: {new_proj['name']} ({new_proj['path']})") - elif new_proj: - print(f"Already in cache: {new_proj['name']}") + config = read_config() + selectors = list(config.get("projects") or []) + if selector in selectors: + print(f"Already enabled: {selector}") else: - print(f"Could not analyze: {project_path}", file=sys.stderr) + selectors.append(selector) + config["projects"] = selectors + write_config(config) + print(f"Enabled: {selector}") + + projects = _run_rescan(projects_dir) + print(f"Found {len(projects)} project(s)") + + +def cmd_remove(args): + """Disable a project by dropping its selector from the enabled list.""" + projects_dir = get_projects_dir() + config = read_config() + selectors = list(config.get("projects") or []) + target = _selector_key(args.name) + kept = [s for s in selectors if _selector_key(s) != target] + if len(kept) == len(selectors): + print(f"Not enabled: {args.name}", file=sys.stderr) sys.exit(1) + config["projects"] = kept + write_config(config) + print(f"Disabled: {args.name}") + + projects = _run_rescan(projects_dir) + print(f"Found {len(projects)} project(s)") def cmd_list(args): - """Show cached projects.""" - projects = read_discovery_cache() - if projects is None: + """Show cached (enabled) projects.""" + cache = read_discovery_cache() + if cache is None: print("No cached projects. Run: opskit projects rescan") sys.exit(0) - project_list = projects.get("projects", []) - projects_dir = projects.get("projects_dir", "unknown") - timestamp = projects.get("timestamp", "unknown") + project_list = cache.get("projects", []) + projects_dir = cache.get("projects_dir", "unknown") + timestamp = cache.get("timestamp", "unknown") print(f"Projects dir: {projects_dir}") print(f"Cache updated: {timestamp}") @@ -208,22 +346,22 @@ def cmd_list(args): print() for proj in project_list: - status = "" - if proj.get("has_resume"): - status = "[resume]" - git_info = proj.get("git_branch", "?") - if proj.get("git_status") == "dirty": - git_info += " *" - print(f" {proj['name']:30s} {git_info:15s} {proj.get('git_status', ''):8s} {status}") + branch, status = ("-", "-") + if (Path(proj["path"]) / ".git").exists(): + branch, status = git_info(proj["path"]) + resume = "[resume]" if proj.get("has_resume") else "" + dirty = " *" if status == "dirty" else "" + print(f" {proj.get('rel', proj['name']):30s} {branch:15s}{dirty:2s} {resume}") def cmd_config(args): - """Show or set the projects_dir path.""" + """Show or set the projects_dir path (and the enabled list).""" config = read_config() - if args.action == "show": - print(f"projects_dir: {config.get('projects_dir', 'not set')}") - elif args.action == "set": + if args.action == "set": + if not args.path: + print("config set requires a path", file=sys.stderr) + sys.exit(1) new_path = Path(args.path).resolve() new_path.mkdir(parents=True, exist_ok=True) config["projects_dir"] = str(new_path) @@ -231,6 +369,10 @@ def cmd_config(args): print(f"Set projects_dir to: {new_path}") else: print(f"projects_dir: {config.get('projects_dir', 'not set')}") + selectors = config.get("projects") or [] + print(f"projects: {len(selectors)} enabled") + for sel in selectors: + print(f" - {sel}") def main(): @@ -238,10 +380,14 @@ def main(): sub = parser.add_subparsers(dest="command") sub.add_parser("rescan", help="Force full rescan of projects dir") + sub.add_parser("bootstrap", help="Enable every OpsKit member found under projects_dir") sub.add_parser("list", help="Show cached projects") - add_parser = sub.add_parser("add", help="Add a single project to cache") - add_parser.add_argument("path", help="Path to project directory") + add_parser = sub.add_parser("add", help="Enable a project and refresh the cache") + add_parser.add_argument("path", help="Path (or name) of the project directory") + + remove_parser = sub.add_parser("remove", help="Disable a project") + remove_parser.add_argument("name", help="Project selector (rel path, basename, or name)") config_parser = sub.add_parser("config", help="Show or set projects_dir") config_parser.add_argument("action", nargs="?", choices=["show", "set"], default="show") @@ -251,8 +397,12 @@ def main(): if args.command == "rescan": cmd_rescan(args) + elif args.command == "bootstrap": + cmd_bootstrap(args) elif args.command == "add": cmd_add(args) + elif args.command == "remove": + cmd_remove(args) elif args.command == "list": cmd_list(args) elif args.command == "config": diff --git a/opskit/_cache.py b/opskit/_cache.py index 39d3da8..c66ecc2 100644 --- a/opskit/_cache.py +++ b/opskit/_cache.py @@ -41,6 +41,8 @@ def read_config() -> dict: with open(CONFIG_FILE, "r") as f: data = yaml.safe_load(f) or {} data.setdefault("projects_dir", str(DEFAULT_PROJECTS_DIR)) + if data.get("projects") is None: + data["projects"] = [] return data @@ -55,6 +57,11 @@ def get_projects_dir() -> str: return read_config().get("projects_dir", str(DEFAULT_PROJECTS_DIR)) +def get_projects() -> list: + """Get the enabled-project allow-list from ~/.opskit/config.yml.""" + return read_config().get("projects", []) or [] + + # ── Helpers ────────────────────────────────────────────────────────────────── def _ensure_cache_dir(): @@ -149,6 +156,14 @@ def cache_is_stale(projects_dir: str) -> bool: if stored_mtime is None or abs(current_mtime - float(stored_mtime)) > 1e-6: return True + # config.yml newer than the cache → the enabled allow-list may have changed. + try: + if (CONFIG_FILE.exists() + and CONFIG_FILE.stat().st_mtime > LAST_DISCOVERY_FILE.stat().st_mtime + 1e-6): + return True + except OSError: + return True + return False From 90089e0bba917d7d36d32c50b68626dee41235d9 Mon Sep 17 00:00:00 2001 From: growlf Date: Mon, 12 Oct 2026 21:56:26 -0700 Subject: [PATCH 3/4] 440: plan --- ...pskit-go-config-driven-enabled-projects.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 plans/440-opskit-go-config-driven-enabled-projects.md diff --git a/plans/440-opskit-go-config-driven-enabled-projects.md b/plans/440-opskit-go-config-driven-enabled-projects.md new file mode 100644 index 0000000..528de16 --- /dev/null +++ b/plans/440-opskit-go-config-driven-enabled-projects.md @@ -0,0 +1,86 @@ +# PLAN: `opskit go` — config-driven enabled-projects list + +Closes #440. + +## Goal + +`opskit go`/`web` must offer **only the user's enabled projects**, read from +`~/.opskit/config.yml`, and must find projects **nested** under `projects_dir`. +Today discovery lists every top-level git repo (102 here) and never recurses, so a +project such as `lfnw/lfnw-django` is invisible and the fzf list is noise. + +## Background + +#437's Goal promised "any **OpsKit-enabled** project", but the mechanism was never +built: `opskit/_cache.py::read_config()` honours only `projects_dir`, and +`~/.opskit/config.yml` is not created at all. `bin/opskit-projects.py::scan_project_dir` +iterates a single level (`iterdir()`) and accepts any directory with `.git/`. + +## Design + +### Config file becomes the source of truth + +`~/.opskit/config.yml`: + +```yaml +projects_dir: /home/user/Projects +projects: + - Systate + - BMS + - lfnw/lfnw-django +``` + +`projects` is a list of selectors, resolved in this order: absolute path, path +relative to `projects_dir`, basename, `pack.yml` `name`. `_cache.py` gains +`get_projects()`; `read_config()` normalises a missing/null list to `[]`. + +### First-run bootstrap + +With an empty `projects` list and a TTY, `opskit go` asks whether to scan +`projects_dir` for existing OpsKit projects (those with `.opskit/pack.yml`) and +enable them. On yes it runs `opskit-projects.py bootstrap`; otherwise it reports +that nothing is enabled and exits. Non-TTY callers never block. + +### Recursive, bounded discovery + +`discover_projects()` walks `projects_dir` (depth ≤ 4), pruning dotdirs, VCS +internals, and build/cache dirs. A directory is a project if it contains `.git/` or +`.opskit/pack.yml`; grouping directories are descended into and matched projects are +not. Git worktrees (`.git` is a file, or a `*-wt-*` name) are skipped by default. +Each record carries a `rel` path so nested projects render and resolve correctly. + +### Enabled filter + +If `projects` is non-empty, discovery is filtered to matching selectors; unmatched +selectors warn on stderr. If empty, `rescan` records all candidates (so `list` still +shows what exists) — the launcher gates on the bootstrap prompt instead. + +### Management + fixes + +- `opskit projects bootstrap|add|remove` maintain the list; `list`/`rescan` respect it. +- Launcher: fix the no-op sort condition, replace substring matching with a + subsequence (fzf-style) match over name/basename/rel, add inline fzf (`--height`), + and make git detail lazy so scanning no longer shells out to `git` per project + (the ~19 s `opskit go` delay). +- Cache invalidation also triggers when `config.yml` is newer than the cache. + +## Critique + +- **Security:** config holds local paths only; atomic writes keep 0600. Selector + matching is by equality against known fields (no path traversal). +- **Stability:** missing/invalid config tolerated; discovery bounded by depth and a + prune set; config-mtime invalidation keeps the cache honest. +- **Maintainability:** discovery stays in `opskit-projects.py`; config semantics live + in `_cache.py` and are imported by both scripts. +- **Effectiveness/Value:** fixes the noise, the nested-match failure, and the scan + delay together; `opskit go` becomes the "one command" the original plan promised. + +## Tests + +`tests/test_cache.py` (config round-trip, config-mtime staleness), +`tests/test_opskit_projects.py` (recursive discovery through a grouping dir, +worktree exclusion, enabled filter, unmatched-selector warning, bootstrap +members-only, add/remove, no git at scan time), `tests/test_tui_matching.py` +(subsequence + nested rel matching). Full suite: `make test`. + +## Ready for review From 76e1ea438f377558908d7e94e8eb037ba58d78be Mon Sep 17 00:00:00 2001 From: growlf Date: Mon, 12 Oct 2026 21:59:24 -0700 Subject: [PATCH 4/4] 440: use generic placeholders in examples (client-data rule) --- bin/opskit-go.py | 2 +- ...40-opskit-go-config-driven-enabled-projects.md | 15 +++++++-------- tests/test_cache.py | 4 ++-- tests/test_opskit_projects.py | 6 +++--- tests/test_tui_matching.py | 12 ++++++------ 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/bin/opskit-go.py b/bin/opskit-go.py index aec1ffe..0df315c 100644 --- a/bin/opskit-go.py +++ b/bin/opskit-go.py @@ -114,7 +114,7 @@ def _subsequence_score(query, target): """Score query as a subsequence of target, or None if it isn't one. Rewards consecutive runs and word-boundary hits (fzf-style) so that - 'lfnw' prefers 'lfnw-django' over an incidental scatter of letters. + 'group' prefers 'group/nested-app' over an incidental scatter of letters. """ qi = 0 score = 0 diff --git a/plans/440-opskit-go-config-driven-enabled-projects.md b/plans/440-opskit-go-config-driven-enabled-projects.md index 528de16..5cb8515 100644 --- a/plans/440-opskit-go-config-driven-enabled-projects.md +++ b/plans/440-opskit-go-config-driven-enabled-projects.md @@ -6,8 +6,8 @@ Closes #440. `opskit go`/`web` must offer **only the user's enabled projects**, read from `~/.opskit/config.yml`, and must find projects **nested** under `projects_dir`. -Today discovery lists every top-level git repo (102 here) and never recurses, so a -project such as `lfnw/lfnw-django` is invisible and the fzf list is noise. +Today discovery lists every top-level git repo and never recurses, so a project +nested under a grouping directory is invisible and the fzf list is noise. ## Background @@ -25,9 +25,9 @@ iterates a single level (`iterdir()`) and accepts any directory with `.git/`. ```yaml projects_dir: /home/user/Projects projects: - - Systate - - BMS - - lfnw/lfnw-django + - alpha + - beta + - group/nested-app ``` `projects` is a list of selectors, resolved in this order: absolute path, path @@ -60,8 +60,7 @@ shows what exists) — the launcher gates on the bootstrap prompt instead. - `opskit projects bootstrap|add|remove` maintain the list; `list`/`rescan` respect it. - Launcher: fix the no-op sort condition, replace substring matching with a subsequence (fzf-style) match over name/basename/rel, add inline fzf (`--height`), - and make git detail lazy so scanning no longer shells out to `git` per project - (the ~19 s `opskit go` delay). + and make git detail lazy so scanning no longer shells out to `git` per project. - Cache invalidation also triggers when `config.yml` is newer than the cache. ## Critique @@ -77,7 +76,7 @@ shows what exists) — the launcher gates on the bootstrap prompt instead. ## Tests -`tests/test_cache.py` (config round-trip, config-mtime staleness), +`tests/test_cache.py` (config round-trip, config-mtime staleness), `tests/test_opskit_projects.py` (recursive discovery through a grouping dir, worktree exclusion, enabled filter, unmatched-selector warning, bootstrap members-only, add/remove, no git at scan time), `tests/test_tui_matching.py` diff --git a/tests/test_cache.py b/tests/test_cache.py index e461362..cf9fc7a 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -230,8 +230,8 @@ def test_default_projects_empty(self, cache_dir): def test_projects_round_trip(self, cache_dir): from opskit import _cache - _cache.write_config({"projects_dir": "/tmp/p", "projects": ["A", "lfnw/x"]}) - assert _cache.get_projects() == ["A", "lfnw/x"] + _cache.write_config({"projects_dir": "/tmp/p", "projects": ["A", "group/x"]}) + assert _cache.get_projects() == ["A", "group/x"] def test_null_projects_normalised_to_empty(self, cache_dir): from opskit import _cache diff --git a/tests/test_opskit_projects.py b/tests/test_opskit_projects.py index 591b16e..6a209c8 100644 --- a/tests/test_opskit_projects.py +++ b/tests/test_opskit_projects.py @@ -161,11 +161,11 @@ def test_keeps_only_listed_projects(self, tree): assert [p["name"] for p in _cache(tree["home"])["projects"]] == ["Demo"] def test_selector_matches_nested_rel_path(self, tree): - (tree["projects"] / "lfnw" / "lfnw-django" / ".git").mkdir(parents=True) + (tree["projects"] / "group" / "nested-app" / ".git").mkdir(parents=True) (tree["projects"] / "Other" / ".git").mkdir(parents=True) - _write_config(tree["home"], tree["projects"], projects=["lfnw/lfnw-django"]) + _write_config(tree["home"], tree["projects"], projects=["group/nested-app"]) _run(["rescan"], tree["env"]) - assert [p["rel"] for p in _cache(tree["home"])["projects"]] == ["lfnw/lfnw-django"] + assert [p["rel"] for p in _cache(tree["home"])["projects"]] == ["group/nested-app"] def test_unmatched_selector_warns(self, tree): (tree["projects"] / "Demo" / ".git").mkdir(parents=True) diff --git a/tests/test_tui_matching.py b/tests/test_tui_matching.py index b43d7aa..79bde05 100644 --- a/tests/test_tui_matching.py +++ b/tests/test_tui_matching.py @@ -81,20 +81,20 @@ class TestFuzzySubsequence: """Subsequence matching over name / basename / nested rel path.""" def test_noncontiguous_subsequence(self): - projects = [{"name": "lfnw-django", "path": "/p/lfnw/lfnw-django"}] - assert len(fuzzy_match_projects("lfdj", projects)) == 1 + projects = [{"name": "nested-app", "path": "/p/group/nested-app"}] + assert len(fuzzy_match_projects("neap", projects)) == 1 def test_matches_nested_rel_path(self): projects = [ - {"name": "lfnw-django", "path": "/p/lfnw/lfnw-django", "rel": "lfnw/lfnw-django"} + {"name": "nested-app", "path": "/p/group/nested-app", "rel": "group/nested-app"} ] - assert len(fuzzy_match_projects("lfnw", projects)) == 1 + assert len(fuzzy_match_projects("group", projects)) == 1 def test_matches_nested_query_form(self): projects = [ - {"name": "lfnw-django", "path": "/p/lfnw/lfnw-django", "rel": "lfnw/lfnw-django"} + {"name": "nested-app", "path": "/p/group/nested-app", "rel": "group/nested-app"} ] - assert len(fuzzy_match_projects("lfnw/lfnw", projects)) == 1 + assert len(fuzzy_match_projects("group/nested", projects)) == 1 def test_rejects_non_subsequence(self): projects = [{"name": "Phoenix", "path": "/p/Phoenix"}]