open-knowledge-studio 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {open_knowledge_studio-0.2.2/open_knowledge_studio.egg-info → open_knowledge_studio-0.2.4}/PKG-INFO +11 -4
  2. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/README.md +10 -3
  3. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/pre-compact.sh +11 -1
  4. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/session-start.sh +7 -1
  5. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.sh +3 -1
  6. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/cli.py +161 -22
  7. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/config.py +60 -14
  8. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/metrics.py +7 -3
  9. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/recall.py +2 -0
  10. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/store.py +34 -32
  11. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4/open_knowledge_studio.egg-info}/PKG-INFO +11 -4
  12. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/SOURCES.txt +1 -0
  13. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/pyproject.toml +1 -1
  14. open_knowledge_studio-0.2.4/setup.py +42 -0
  15. open_knowledge_studio-0.2.4/tests/test_init.py +90 -0
  16. open_knowledge_studio-0.2.2/tests/test_init.py +0 -50
  17. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/LICENSE +0 -0
  18. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/MANIFEST.in +0 -0
  19. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/__init__.py +0 -0
  20. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/frontmatter-schema.md +0 -0
  21. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/learning-schema.json +0 -0
  22. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/_meta/raw-evidence-schema.md +0 -0
  23. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/user-prompt-recall.py +0 -0
  24. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/hooks/validate-wiki-write.sh +0 -0
  25. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/rules/raw-intake.md +0 -0
  26. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/rules/wiki-writing.md +0 -0
  27. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/settings.json +0 -0
  28. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/archive/SKILL.md +0 -0
  29. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/compile/SKILL.md +0 -0
  30. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/ingest/SKILL.md +0 -0
  31. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/lint/SKILL.md +0 -0
  32. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/promote/SKILL.md +0 -0
  33. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/query/SKILL.md +0 -0
  34. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/start/SKILL.md +0 -0
  35. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/claude/skills/status/SKILL.md +0 -0
  36. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/handlers.json +0 -0
  37. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/input-sources.json +0 -0
  38. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/settings/raw-tools.example.json +0 -0
  39. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/anti-pattern.md +0 -0
  40. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/concept.md +0 -0
  41. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/draft.md +0 -0
  42. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/_assets/templates/strategy.md +0 -0
  43. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/distiller.py +0 -0
  44. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/knowledge_studio/health.py +0 -0
  45. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/dependency_links.txt +0 -0
  46. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/entry_points.txt +0 -0
  47. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/requires.txt +0 -0
  48. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/open_knowledge_studio.egg-info/top_level.txt +0 -0
  49. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/setup.cfg +0 -0
  50. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/tests/test_distiller.py +0 -0
  51. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/tests/test_health.py +0 -0
  52. {open_knowledge_studio-0.2.2 → open_knowledge_studio-0.2.4}/tests/test_recall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -42,16 +42,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
42
42
  ## Install
43
43
 
44
44
  ```bash
45
- pip install open-knowledge-studio
45
+ pipx install open-knowledge-studio && pipx ensurepath
46
46
  ```
47
47
 
48
+ We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
49
+ PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
50
+ PyPI, add `--pip-args="-i https://pypi.org/simple"`.
51
+
48
52
  Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
49
53
  separate, heavier package that you can pull in on demand:
50
54
 
51
55
  ```bash
52
- pip install "open-knowledge-studio[connector]"
56
+ pipx inject open-knowledge-studio oks-connector
53
57
  ```
54
58
 
59
+ (pipx apps share one venv without a standalone pip executable — `pipx inject` is how
60
+ you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
61
+
55
62
  ## What you get
56
63
 
57
64
  - **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
@@ -67,7 +74,7 @@ orchestrate the pipeline around it.
67
74
  ## Quick start
68
75
 
69
76
  ```bash
70
- pip install open-knowledge-studio # 1. install the CLI
77
+ pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
71
78
  oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
72
79
  cd my-knowledge-base
73
80
  oks status # 3. use it
@@ -8,16 +8,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- pip install open-knowledge-studio
11
+ pipx install open-knowledge-studio && pipx ensurepath
12
12
  ```
13
13
 
14
+ We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
15
+ PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
16
+ PyPI, add `--pip-args="-i https://pypi.org/simple"`.
17
+
14
18
  Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
15
19
  separate, heavier package that you can pull in on demand:
16
20
 
17
21
  ```bash
18
- pip install "open-knowledge-studio[connector]"
22
+ pipx inject open-knowledge-studio oks-connector
19
23
  ```
20
24
 
25
+ (pipx apps share one venv without a standalone pip executable — `pipx inject` is how
26
+ you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
27
+
21
28
  ## What you get
22
29
 
23
30
  - **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
@@ -33,7 +40,7 @@ orchestrate the pipeline around it.
33
40
  ## Quick start
34
41
 
35
42
  ```bash
36
- pip install open-knowledge-studio # 1. install the CLI
43
+ pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
37
44
  oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
38
45
  cd my-knowledge-base
39
46
  oks status # 3. use it
@@ -4,7 +4,17 @@
4
4
 
5
5
  set -euo pipefail
6
6
 
7
- REPO_ROOT="${OKS_ROOT:-$(pwd)}"
7
+ REPO_ROOT="${OKS_ROOT:-}"
8
+ if [ -z "$REPO_ROOT" ]; then
9
+ REPO_ROOT="$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.oks/config.json'))).get('knowledge_base_path',''))" 2>/dev/null || true)"
10
+ fi
11
+ if [ -z "$REPO_ROOT" ]; then
12
+ REPO_ROOT="$(pwd)"
13
+ fi
14
+
15
+ # Only snapshot inside a real knowledge base — never litter other dirs.
16
+ [ -d "$REPO_ROOT/wiki" ] || exit 0
17
+
8
18
  SNAPSHOT_DIR="$REPO_ROOT/.oks/snapshots"
9
19
  mkdir -p "$SNAPSHOT_DIR"
10
20
 
@@ -4,7 +4,13 @@
4
4
 
5
5
  set -euo pipefail
6
6
 
7
- REPO_ROOT="${OKS_ROOT:-$(pwd)}"
7
+ REPO_ROOT="${OKS_ROOT:-}"
8
+ if [ -z "$REPO_ROOT" ]; then
9
+ REPO_ROOT="$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.oks/config.json'))).get('knowledge_base_path',''))" 2>/dev/null || true)"
10
+ fi
11
+ if [ -z "$REPO_ROOT" ]; then
12
+ REPO_ROOT="$(pwd)"
13
+ fi
8
14
 
9
15
  if [ ! -d "$REPO_ROOT/wiki" ]; then
10
16
  exit 0
@@ -3,4 +3,6 @@
3
3
  # Passes the original stdin (editor JSON payload) through to the Python hook,
4
4
  # and drops jieba's stderr chatter so only the clean <recalled-memory> block
5
5
  # reaches stdout. Fails open (exit 0) so a prompt is never blocked.
6
- exec python3 "$(dirname "$0")/user-prompt-recall.py" 2>/dev/null
6
+ # OKS_PYTHON is baked in by `oks hook install` to point at the interpreter
7
+ # that can import knowledge_studio (pipx/venv safe); falls back to python3.
8
+ exec "${OKS_PYTHON:-python3}" "$(dirname "$0")/user-prompt-recall.py" 2>/dev/null
@@ -24,6 +24,27 @@ app = typer.Typer(
24
24
  )
25
25
  console = Console()
26
26
 
27
+
28
+ def _version_callback(value: bool):
29
+ if value:
30
+ from importlib.metadata import version, PackageNotFoundError
31
+ try:
32
+ console.print(f"oks {version('open-knowledge-studio')}")
33
+ except PackageNotFoundError:
34
+ console.print("oks (development, not installed as a package)")
35
+ raise typer.Exit()
36
+
37
+
38
+ @app.callback()
39
+ def _main(
40
+ version: bool = typer.Option(
41
+ False, "--version", "-V", callback=_version_callback, is_eager=True,
42
+ help="Show the oks version and exit.",
43
+ ),
44
+ ):
45
+ pass
46
+
47
+
27
48
  wiki_app = typer.Typer(help="Wiki page management.")
28
49
  drafts_app = typer.Typer(help="Draft proposal management.")
29
50
  config_app = typer.Typer(help="Global configuration (~/.oks/config.json).")
@@ -191,8 +212,18 @@ def wiki_create(
191
212
  if not content and not sys.stdin.isatty():
192
213
  content = sys.stdin.read()
193
214
 
194
- type_map = {"concept": "concepts", "strategy": "strategies", "anti-pattern": "anti-patterns"}
195
- wiki_type = type_map.get(page_type, "concepts")
215
+ type_map = {
216
+ "concept": "concepts", "concepts": "concepts",
217
+ "strategy": "strategies", "strategies": "strategies",
218
+ "anti-pattern": "anti-patterns", "anti-patterns": "anti-patterns",
219
+ }
220
+ wiki_type = type_map.get(page_type)
221
+ if wiki_type is None:
222
+ console.print(
223
+ f"[yellow]Unknown --type '{page_type}' — using 'concept'. "
224
+ f"Valid: concept, strategy, anti-pattern.[/yellow]"
225
+ )
226
+ wiki_type = "concepts"
196
227
 
197
228
  path = store.write_wiki_page(
198
229
  title=title,
@@ -444,7 +475,11 @@ def config_init(
444
475
  """Initialize global config at ~/.oks/config.json."""
445
476
  from knowledge_studio.config import init_config
446
477
 
447
- path = init_config(kb_path)
478
+ try:
479
+ path = init_config(kb_path)
480
+ except ValueError as e:
481
+ console.print(f"[red]{e}[/red]")
482
+ raise typer.Exit(1)
448
483
  console.print(f"[green]Config created:[/green] {path}")
449
484
 
450
485
  from knowledge_studio.config import load_config
@@ -495,7 +530,15 @@ def config_set(
495
530
  target[k] = {}
496
531
  target = target[k]
497
532
 
498
- if value.lower() in ("true", "false"):
533
+ if key == "knowledge_base_path":
534
+ resolved = Path(value).expanduser().resolve()
535
+ if not resolved.is_dir():
536
+ console.print(
537
+ f"[yellow]Warning:[/yellow] directory does not exist: {resolved}"
538
+ )
539
+ value = str(resolved)
540
+ target[keys[-1]] = value
541
+ elif value.lower() in ("true", "false"):
499
542
  target[keys[-1]] = value.lower() == "true"
500
543
  elif value.isdigit():
501
544
  target[keys[-1]] = int(value)
@@ -576,18 +619,17 @@ def _materialize_assets(root: Path, base: Path, is_packaged: bool, overwrite: bo
576
619
  if not src.is_dir():
577
620
  continue
578
621
  dest = root / dest_name
579
- if dest.exists():
580
- if not overwrite:
581
- continue
582
- shutil.rmtree(dest)
583
- shutil.copytree(src, dest)
622
+ if dest.exists() and not overwrite:
623
+ continue
624
+ # Merge-copy: refresh bundled files in place, keep user-owned files.
625
+ shutil.copytree(src, dest, dirs_exist_ok=True)
584
626
  done.append(dest_name)
585
627
  return done
586
628
 
587
629
 
588
630
  @app.command()
589
631
  def init(
590
- path: str = typer.Argument(".", help="Target directory for the new knowledge instance"),
632
+ path: str = typer.Argument(..., help="Target directory for the new knowledge instance"),
591
633
  set_default: bool = typer.Option(
592
634
  True, "--set-default/--no-set-default",
593
635
  help="Register this folder as the active KB in ~/.oks/config.json",
@@ -599,6 +641,10 @@ def init(
599
641
  False, "--upgrade",
600
642
  help="Re-copy bundled assets (skills/templates/_meta/settings), overwriting them; your memory (wiki/drafts/profiles) is untouched",
601
643
  ),
644
+ force: bool = typer.Option(
645
+ False, "--force",
646
+ help="Scaffold into a non-empty directory that is not already a knowledge base",
647
+ ),
602
648
  ):
603
649
  """Scaffold a new knowledge INSTANCE folder (e.g. your personal artboy-knowledge-studio).
604
650
 
@@ -608,6 +654,26 @@ def init(
608
654
  from anywhere.
609
655
  """
610
656
  root = Path(path).expanduser().resolve()
657
+
658
+ # Refuse to scaffold into an existing non-empty directory that is not
659
+ # already a KB (missing wiki/) — protects arbitrary folders from being
660
+ # hijacked. Re-running on an existing KB is idempotent and allowed.
661
+ if (
662
+ root.is_dir()
663
+ and any(root.iterdir())
664
+ and not (root / "wiki").is_dir()
665
+ and not force
666
+ ):
667
+ console.print(
668
+ f"[red]Refusing to scaffold into non-empty directory:[/red] {root}\n"
669
+ f"It does not look like a knowledge base (no wiki/). Init would create:\n"
670
+ + "\n".join(f" - {d}/" for d in _INSTANCE_DIRS)
671
+ + "\n - .claude/ templates/ _meta/ settings/ (bundled assets)"
672
+ + "\n - .gitignore"
673
+ + "\n\nRe-run with [bold]--force[/bold] to proceed anyway."
674
+ )
675
+ raise typer.Exit(1)
676
+
611
677
  root.mkdir(parents=True, exist_ok=True)
612
678
 
613
679
  for d in _INSTANCE_DIRS:
@@ -617,7 +683,11 @@ def init(
617
683
 
618
684
  base, is_packaged = _asset_source()
619
685
  if base is None:
620
- console.print("[yellow]No bundled assets found — skills/templates not materialized.[/yellow]")
686
+ console.print(
687
+ "[yellow]No bundled assets found — skills/templates not materialized.[/yellow]\n"
688
+ " Source installs lack the asset bundle. Fix: pip install open-knowledge-studio,\n"
689
+ " or run python cli/scripts/bundle_assets.py in the repo before installing."
690
+ )
621
691
  else:
622
692
  copied = _materialize_assets(root, base, is_packaged, overwrite=upgrade)
623
693
  if copied:
@@ -658,7 +728,10 @@ def init(
658
728
 
659
729
  # ── Optional editor hooks (opt-in auto-recall) ───────────────────
660
730
 
661
- _RECALL_HOOK_CMD = ".claude/hooks/user-prompt-recall.sh"
731
+ # Hook commands are written as absolute paths (see hook_install). Old
732
+ # installs wired the relative path below; matching is done by script name
733
+ # so both forms are recognized.
734
+ _RECALL_HOOK_SCRIPT_NAME = "user-prompt-recall.sh"
662
735
  _RECALL_HOOK_SCRIPTS = ("user-prompt-recall.py", "user-prompt-recall.sh")
663
736
  _HOOK_EDITORS = {
664
737
  "claude": ".claude/settings.json",
@@ -674,9 +747,16 @@ def _instance_root(path: str | None) -> Path:
674
747
 
675
748
 
676
749
  def _ensure_recall_scripts(root: Path) -> list[str]:
677
- """Copy the recall hook scripts into <root>/.claude/hooks/ if missing."""
750
+ """Copy/refresh the recall hook scripts in <root>/.claude/hooks/.
751
+
752
+ The .sh wrapper gets the current interpreter baked into its OKS_PYTHON
753
+ fallback. If an existing .sh lacks the current bake (fresh copy still on
754
+ `python3`, or baked against a stale interpreter), it is re-copied from
755
+ the asset source and re-baked. The .py engine is only copied if missing.
756
+ """
678
757
  import shutil
679
758
  import stat
759
+ import sys
680
760
 
681
761
  hooks_dir = root / ".claude" / "hooks"
682
762
  hooks_dir.mkdir(parents=True, exist_ok=True)
@@ -686,23 +766,40 @@ def _ensure_recall_scripts(root: Path) -> list[str]:
686
766
  if base is not None:
687
767
  src_dir = base / ("claude/hooks" if is_packaged else ".claude/hooks")
688
768
 
769
+ baked = f'"${{OKS_PYTHON:-{sys.executable}}}"'
689
770
  created: list[str] = []
690
771
  for name in _RECALL_HOOK_SCRIPTS:
691
772
  dest = hooks_dir / name
692
773
  if dest.exists():
693
- continue
774
+ if not name.endswith(".sh"):
775
+ continue
776
+ try:
777
+ if baked in dest.read_text(encoding="utf-8"):
778
+ continue
779
+ except OSError:
780
+ pass
781
+ # Stale interpreter bake — fall through to re-copy + re-bake.
694
782
  if src_dir is None or not (src_dir / name).is_file():
695
783
  raise FileNotFoundError(
696
784
  f"bundled hook script not found: {name} (asset source: {src_dir})"
697
785
  )
698
786
  shutil.copy2(src_dir / name, dest)
787
+ if name.endswith(".sh"):
788
+ text = dest.read_text(encoding="utf-8").replace(
789
+ '"${OKS_PYTHON:-python3}"', baked
790
+ )
791
+ dest.write_text(text, encoding="utf-8")
699
792
  dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
700
793
  created.append(name)
701
794
  return created
702
795
 
703
796
 
704
797
  def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
705
- """Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'."""
798
+ """Idempotently add a UserPromptSubmit command hook. Returns 'wired'|'exists'.
799
+
800
+ Recognizes previously wired entries (old relative or stale absolute
801
+ paths) by script name and rewrites them in place instead of duplicating.
802
+ """
706
803
  data: dict = {}
707
804
  if settings_path.exists():
708
805
  try:
@@ -711,11 +808,18 @@ def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
711
808
  raise ValueError(f"{settings_path} is not valid JSON: {e}") from e
712
809
  hooks = data.setdefault("hooks", {})
713
810
  ups = hooks.setdefault("UserPromptSubmit", [])
811
+ stale: dict | None = None
714
812
  for group in ups:
715
813
  for h in group.get("hooks", []):
716
- if h.get("command") == command:
814
+ cmd = h.get("command", "")
815
+ if cmd == command:
717
816
  return "exists"
718
- ups.append({"hooks": [{"type": "command", "command": command}]})
817
+ if cmd.endswith(_RECALL_HOOK_SCRIPT_NAME):
818
+ stale = h
819
+ if stale is not None:
820
+ stale["command"] = command
821
+ else:
822
+ ups.append({"hooks": [{"type": "command", "command": command}]})
719
823
  settings_path.parent.mkdir(parents=True, exist_ok=True)
720
824
  settings_path.write_text(
721
825
  json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
@@ -723,7 +827,7 @@ def _wire_userpromptsubmit(settings_path: Path, command: str) -> str:
723
827
  return "wired"
724
828
 
725
829
 
726
- def _hook_is_wired(settings_path: Path, command: str) -> bool:
830
+ def _hook_is_wired(settings_path: Path) -> bool:
727
831
  if not settings_path.exists():
728
832
  return False
729
833
  try:
@@ -732,7 +836,7 @@ def _hook_is_wired(settings_path: Path, command: str) -> bool:
732
836
  return False
733
837
  for group in data.get("hooks", {}).get("UserPromptSubmit", []):
734
838
  for h in group.get("hooks", []):
735
- if h.get("command") == command:
839
+ if h.get("command", "").endswith(_RECALL_HOOK_SCRIPT_NAME):
736
840
  return True
737
841
  return False
738
842
 
@@ -757,19 +861,37 @@ def hook_install(
757
861
  console.print("[red]--editor must be one of: claude, qoder, both[/red]")
758
862
  raise typer.Exit(1)
759
863
 
864
+ import platform
865
+ if platform.system() == "Windows":
866
+ console.print(
867
+ "[yellow]Warning: hooks are bash scripts and will not run on native Windows.[/yellow]\n"
868
+ " Use WSL (or Git Bash configured as the hook shell) for auto-recall to work."
869
+ )
870
+
760
871
  root = _instance_root(path)
761
872
  if not root.is_dir():
762
873
  console.print(f"[red]Instance root not found:[/red] {root}")
763
874
  raise typer.Exit(1)
764
875
 
765
- created = _ensure_recall_scripts(root)
876
+ try:
877
+ created = _ensure_recall_scripts(root)
878
+ except FileNotFoundError as e:
879
+ console.print(
880
+ f"[red]Cannot install hook — bundled assets missing.[/red]\n"
881
+ f" {e}\n"
882
+ f" This happens when oks was installed from source without the asset bundle.\n"
883
+ f" Fix: [bold]pip install open-knowledge-studio[/bold] (PyPI wheel includes assets),\n"
884
+ f" or run [bold]python cli/scripts/bundle_assets.py[/bold] in the repo before installing."
885
+ )
886
+ raise typer.Exit(1)
766
887
  if created:
767
888
  console.print(f"[green]Installed hook script:[/green] {', '.join(created)}")
768
889
 
890
+ hook_cmd = (root / ".claude" / "hooks" / _RECALL_HOOK_SCRIPT_NAME).resolve().as_posix()
769
891
  editors = ("claude", "qoder") if editor == "both" else (editor,)
770
892
  for name in editors:
771
893
  settings_path = root / _HOOK_EDITORS[name]
772
- result = _wire_userpromptsubmit(settings_path, _RECALL_HOOK_CMD)
894
+ result = _wire_userpromptsubmit(settings_path, hook_cmd)
773
895
  label = "[green]wired[/green]" if result == "wired" else "[dim]already wired[/dim]"
774
896
  console.print(f" {name}: {label} → {settings_path}")
775
897
 
@@ -788,9 +910,26 @@ def hook_status(
788
910
  script = root / ".claude" / "hooks" / "user-prompt-recall.sh"
789
911
  console.print(f"[bold]Instance:[/bold] {root}")
790
912
  console.print(f" script: {'present' if script.is_file() else 'missing'} ({script})")
913
+ if script.is_file():
914
+ import os
915
+ import re
916
+ import subprocess
917
+ m = re.search(r"\$\{OKS_PYTHON:-([^}]+)\}", script.read_text(encoding="utf-8"))
918
+ py = os.environ.get("OKS_PYTHON") or (m.group(1) if m else "python3")
919
+ try:
920
+ ok = subprocess.run(
921
+ [py, "-c", "import knowledge_studio"],
922
+ capture_output=True, timeout=15,
923
+ ).returncode == 0
924
+ except (OSError, subprocess.TimeoutExpired):
925
+ ok = False
926
+ state = ("[green]importable[/green]" if ok
927
+ else "[red]hook script has stale interpreter — "
928
+ "run `oks hook install` to re-bake[/red]")
929
+ console.print(f" engine: {state} (python: {py})")
791
930
  for name, rel in _HOOK_EDITORS.items():
792
931
  settings_path = root / rel
793
- wired = _hook_is_wired(settings_path, _RECALL_HOOK_CMD)
932
+ wired = _hook_is_wired(settings_path)
794
933
  state = "[green]wired[/green]" if wired else "[dim]not wired[/dim]"
795
934
  console.print(f" {name}: {state}")
796
935
 
@@ -61,8 +61,14 @@ def load_config() -> dict[str, Any]:
61
61
  path = config_path()
62
62
  if not path.exists():
63
63
  return dict(DEFAULT_CONFIG)
64
- with open(path) as f:
65
- return json.load(f)
64
+ try:
65
+ with open(path) as f:
66
+ return json.load(f)
67
+ except json.JSONDecodeError as e:
68
+ raise ValueError(
69
+ f"{path} is corrupt ({e}). Fix the JSON manually, or delete it and "
70
+ f"re-run `oks config init` / `oks init <path>`."
71
+ ) from e
66
72
 
67
73
 
68
74
  def save_config(config: dict[str, Any]) -> None:
@@ -70,6 +76,7 @@ def save_config(config: dict[str, Any]) -> None:
70
76
  path = config_path()
71
77
  path.parent.mkdir(parents=True, exist_ok=True)
72
78
 
79
+ import contextlib
73
80
  import tempfile
74
81
  fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
75
82
  try:
@@ -77,42 +84,81 @@ def save_config(config: dict[str, Any]) -> None:
77
84
  json.dump(config, f, indent=2, ensure_ascii=False)
78
85
  os.replace(tmp, path)
79
86
  except Exception:
80
- os.unlink(tmp)
87
+ with contextlib.suppress(OSError):
88
+ os.unlink(tmp)
81
89
  raise
82
90
 
83
91
 
84
92
  def init_config(kb_path: str | None = None) -> Path:
85
- """Initialize global config. Returns the config path."""
93
+ """Initialize global config. Returns the config path.
94
+
95
+ Raises ValueError when no kb_path is given and the existing config has
96
+ no knowledge_base_path — we never silently default to cwd.
97
+ """
86
98
  config = load_config()
87
99
 
88
100
  if kb_path:
89
- config["knowledge_base_path"] = kb_path
101
+ config["knowledge_base_path"] = str(Path(kb_path).expanduser().resolve())
90
102
  elif not config.get("knowledge_base_path"):
91
- try:
92
- from knowledge_studio.store import repo_root
93
- config["knowledge_base_path"] = str(repo_root())
94
- except Exception:
95
- config["knowledge_base_path"] = str(Path.cwd())
103
+ raise ValueError(
104
+ "knowledge_base_path required: pass --kb-path or run `oks init <path>`"
105
+ )
96
106
 
97
107
  save_config(config)
98
108
  return config_path()
99
109
 
100
110
 
111
+ # Warn at most once per process when the configured root lacks wiki/.
112
+ _warned_missing_wiki = False
113
+
114
+
115
+ def _warn_if_not_kb(root: Path) -> None:
116
+ global _warned_missing_wiki
117
+ if _warned_missing_wiki:
118
+ return
119
+ if not (root / "wiki").is_dir():
120
+ _warned_missing_wiki = True
121
+ import sys
122
+ print(
123
+ f"oks: warning: configured KB path {root} does not look like a "
124
+ f"knowledge base (missing wiki/); run `oks init <path>` or "
125
+ f"`oks config set knowledge_base_path <path>`",
126
+ file=sys.stderr,
127
+ )
128
+
129
+
101
130
  def get_kb_root() -> Path:
102
- """Get the knowledge base root path.
131
+ """Get the knowledge base root path (single source of truth).
103
132
 
104
133
  Priority:
105
134
  1. OKS_ROOT env var
106
135
  2. ~/.oks/config.json → knowledge_base_path
107
136
  3. Current working directory
137
+
138
+ A corrupt config warns on stderr and falls back to cwd instead of
139
+ raising, matching the historical store.repo_root() behavior.
108
140
  """
109
141
  env_root = os.environ.get("OKS_ROOT")
110
142
  if env_root:
111
- return Path(env_root)
143
+ root = Path(env_root).expanduser().resolve()
144
+ _warn_if_not_kb(root)
145
+ return root
146
+
147
+ try:
148
+ config = load_config()
149
+ except Exception as e:
150
+ import sys
151
+ print(
152
+ f"oks: warning: could not read {config_path()} ({e}); "
153
+ f"falling back to current directory as KB root",
154
+ file=sys.stderr,
155
+ )
156
+ return Path.cwd()
112
157
 
113
- config = load_config()
114
158
  kb_path = config.get("knowledge_base_path")
115
159
  if kb_path:
116
- return Path(kb_path)
160
+ root = Path(kb_path).expanduser().resolve()
161
+ _warn_if_not_kb(root)
162
+ return root
117
163
 
118
164
  return Path.cwd()
@@ -5,7 +5,7 @@ Removed KnowledgeStore dependency. Uses store functions.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
- from datetime import UTC, datetime, timedelta
8
+ from datetime import UTC, date, datetime, timedelta
9
9
 
10
10
  from knowledge_studio.store import list_wiki_pages, repo_root, wiki_dir
11
11
 
@@ -86,11 +86,15 @@ def _compute_credibility(pages: list[dict], ninety_days_ago: datetime) -> dict:
86
86
  }
87
87
 
88
88
 
89
- def _parse_date(value: str | None) -> datetime:
89
+ def _parse_date(value) -> datetime:
90
90
  if not value:
91
91
  return datetime.min.replace(tzinfo=UTC)
92
+ if isinstance(value, datetime):
93
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
94
+ if isinstance(value, date):
95
+ return datetime(value.year, value.month, value.day, tzinfo=UTC)
92
96
  try:
93
- dt = datetime.fromisoformat(value)
97
+ dt = datetime.fromisoformat(str(value))
94
98
  if dt.tzinfo is None:
95
99
  dt = dt.replace(tzinfo=UTC)
96
100
  return dt
@@ -221,6 +221,8 @@ def _tokenize(text: str) -> set[str]:
221
221
  try:
222
222
  import jieba
223
223
 
224
+ import logging as _logging
225
+ jieba.setLogLevel(_logging.WARNING) # silence "Building prefix dict" chatter
224
226
  raw_words = list(jieba.cut_for_search(text))
225
227
  except Exception:
226
228
  raw_words = text.split()
@@ -13,7 +13,7 @@ import math
13
13
  import os
14
14
  import re
15
15
  import tempfile
16
- from datetime import UTC, datetime
16
+ from datetime import UTC, date, datetime
17
17
  from pathlib import Path
18
18
 
19
19
  import yaml
@@ -35,18 +35,10 @@ DEFAULT_CONFIG: dict = {
35
35
 
36
36
 
37
37
  def repo_root() -> Path:
38
- env_root = os.environ.get("OKS_ROOT")
39
- if env_root:
40
- return Path(env_root)
41
- try:
42
- from knowledge_studio.config import load_config
38
+ """Thin delegate — config.get_kb_root() is the single root resolver."""
39
+ from knowledge_studio.config import get_kb_root
43
40
 
44
- kb_path = load_config().get("knowledge_base_path")
45
- if kb_path:
46
- return Path(kb_path)
47
- except Exception:
48
- pass
49
- return Path(os.getcwd())
41
+ return get_kb_root()
50
42
 
51
43
 
52
44
  def wiki_dir() -> Path:
@@ -103,9 +95,9 @@ def load_active_goals() -> list[dict]:
103
95
 
104
96
 
105
97
  def _access_log_path() -> Path:
106
- log_dir = repo_root() / ".oks"
107
- log_dir.mkdir(parents=True, exist_ok=True)
108
- return log_dir / "access.json"
98
+ # Read-only: no mkdir here. Writers go through _atomic_write, which
99
+ # creates the parent directory.
100
+ return repo_root() / ".oks" / "access.json"
109
101
 
110
102
 
111
103
  def _load_access_counts() -> dict[str, int]:
@@ -187,11 +179,16 @@ def compute_score(meta: dict, access_count: int = 0, config: dict | None = None)
187
179
  if archived or status == "dropped":
188
180
  return 0.0
189
181
 
190
- created_str = meta.get("created", "")
191
- try:
192
- created = datetime.fromisoformat(created_str)
193
- except (ValueError, TypeError):
194
- created = datetime.now(UTC)
182
+ created_raw = meta.get("created", "")
183
+ if isinstance(created_raw, datetime):
184
+ created = created_raw
185
+ elif isinstance(created_raw, date):
186
+ created = datetime(created_raw.year, created_raw.month, created_raw.day)
187
+ else:
188
+ try:
189
+ created = datetime.fromisoformat(str(created_raw))
190
+ except (ValueError, TypeError):
191
+ created = datetime.now(UTC)
195
192
 
196
193
  tz = UTC if not created.tzinfo else created.tzinfo
197
194
  days_old = max(0, (datetime.now(UTC) - created.replace(tzinfo=tz)).days)
@@ -245,9 +242,9 @@ def _fingerprint(content: str) -> str:
245
242
 
246
243
 
247
244
  def _fingerprint_index_path() -> Path:
248
- d = repo_root() / ".oks"
249
- d.mkdir(parents=True, exist_ok=True)
250
- return d / "fingerprints.json"
245
+ # Read-only: no mkdir here. Writers go through _atomic_write, which
246
+ # creates the parent directory.
247
+ return repo_root() / ".oks" / "fingerprints.json"
251
248
 
252
249
 
253
250
  def _load_fingerprint_index() -> dict[str, str]:
@@ -274,18 +271,19 @@ def _find_file_by_slug(slug: str) -> Path | None:
274
271
  return None
275
272
 
276
273
 
277
- def _update_frontmatter_field(file_path: Path, field: str, value) -> None:
274
+ def _update_frontmatter_field(file_path: Path, field: str, value) -> bool:
278
275
  text = file_path.read_text(encoding="utf-8")
279
276
  parts = text.split("---", 2)
280
277
  if len(parts) < 3:
281
- return
278
+ return False
282
279
  try:
283
280
  meta = yaml.safe_load(parts[1].strip()) or {}
284
281
  except yaml.YAMLError:
285
- return
282
+ return False
286
283
  meta[field] = value
287
284
  new_fm = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
288
285
  _atomic_write(file_path, f"---\n{new_fm}---\n{parts[2]}")
286
+ return True
289
287
 
290
288
 
291
289
  def _reinforce_confidence(slug: str) -> None:
@@ -493,17 +491,16 @@ def pin_page(slug: str) -> bool:
493
491
  f = _find_file_by_slug(slug)
494
492
  if not f:
495
493
  return False
496
- _update_frontmatter_field(f, "pinned", True)
497
- return True
494
+ return _update_frontmatter_field(f, "pinned", True)
498
495
 
499
496
 
500
497
  def archive_page(slug: str) -> bool:
501
498
  f = _find_file_by_slug(slug)
502
499
  if not f:
503
500
  return False
504
- _update_frontmatter_field(f, "status", "dropped")
505
- _update_frontmatter_field(f, "archived", True)
506
- return True
501
+ dropped = _update_frontmatter_field(f, "status", "dropped")
502
+ archived = _update_frontmatter_field(f, "archived", True)
503
+ return dropped and archived
507
504
 
508
505
 
509
506
  def list_drafts() -> list[dict]:
@@ -547,7 +544,12 @@ def promote_draft(
547
544
  body = meta.get("body", "")
548
545
 
549
546
  final_title = title or meta.get("title", slug)
550
- final_type = (wiki_type or meta.get("draft_type", "concepts")).rstrip("s") + "s"
547
+ _type_dirs = {
548
+ "concept": "concepts", "concepts": "concepts",
549
+ "strategy": "strategies", "strategies": "strategies",
550
+ "anti-pattern": "anti-patterns", "anti-patterns": "anti-patterns",
551
+ }
552
+ final_type = _type_dirs.get(wiki_type or meta.get("draft_type", "concept"), "concepts")
551
553
  final_area = area or meta.get("draft_area", "computing")
552
554
  human_note = meta.get("source_note") or None
553
555
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-knowledge-studio
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: File-based knowledge engineering CLI for Claude Code
5
5
  Author: open-agent-power
6
6
  License: MIT
@@ -42,16 +42,23 @@ a file-based knowledge base that turns raw material into a recallable, self-deca
42
42
  ## Install
43
43
 
44
44
  ```bash
45
- pip install open-knowledge-studio
45
+ pipx install open-knowledge-studio && pipx ensurepath
46
46
  ```
47
47
 
48
+ We recommend pipx because modern Linux (Ubuntu 24.04+) and macOS Homebrew Pythons are
49
+ PEP 668 externally-managed, so a bare `pip install` fails. If your mirror lags behind
50
+ PyPI, add `--pip-args="-i https://pypi.org/simple"`.
51
+
48
52
  Optional multimodal ingest (PDF / audio / video / formula extraction) lives in a
49
53
  separate, heavier package that you can pull in on demand:
50
54
 
51
55
  ```bash
52
- pip install "open-knowledge-studio[connector]"
56
+ pipx inject open-knowledge-studio oks-connector
53
57
  ```
54
58
 
59
+ (pipx apps share one venv without a standalone pip executable — `pipx inject` is how
60
+ you add extras. In a plain venv, use `pip install "open-knowledge-studio[connector]"`.)
61
+
55
62
  ## What you get
56
63
 
57
64
  - **6+1-factor recall engine** — token overlap, substring, topic trace, type boost,
@@ -67,7 +74,7 @@ orchestrate the pipeline around it.
67
74
  ## Quick start
68
75
 
69
76
  ```bash
70
- pip install open-knowledge-studio # 1. install the CLI
77
+ pipx install open-knowledge-studio && pipx ensurepath # 1. install the CLI
71
78
  oks init my-knowledge-base # 2. scaffold an instance (skills + buckets)
72
79
  cd my-knowledge-base
73
80
  oks status # 3. use it
@@ -2,6 +2,7 @@ LICENSE
2
2
  MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
+ setup.py
5
6
  knowledge_studio/__init__.py
6
7
  knowledge_studio/cli.py
7
8
  knowledge_studio/config.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-knowledge-studio"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "File-based knowledge engineering CLI for Claude Code"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,42 @@
1
+ """Build hook: bundle the shareable asset layer during any source build.
2
+
3
+ The repo root is the single source of truth for `.claude/`, `templates/`,
4
+ `_meta/`, `settings/`. When building from a git checkout (pip install ./cli,
5
+ pip install git+...#subdirectory=cli, python -m build), copy them into
6
+ `knowledge_studio/_assets/` before build_py runs, so source installs are
7
+ identical to PyPI wheels. When building from an sdist the repo root is
8
+ absent and `_assets/` is already included via MANIFEST.in — skip silently.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ from setuptools import setup
16
+ from setuptools.command.build_py import build_py
17
+
18
+ _MAP = [
19
+ (".claude", "claude"),
20
+ ("templates", "templates"),
21
+ ("_meta", "_meta"),
22
+ ("settings", "settings"),
23
+ ]
24
+
25
+
26
+ class build_py_with_assets(build_py):
27
+ def run(self):
28
+ cli_dir = Path(__file__).resolve().parent
29
+ repo_root = cli_dir.parent
30
+ if (repo_root / ".claude").is_dir() and (repo_root / "templates").is_dir():
31
+ dest_root = cli_dir / "knowledge_studio" / "_assets"
32
+ if dest_root.exists():
33
+ shutil.rmtree(dest_root)
34
+ dest_root.mkdir(parents=True)
35
+ for src_name, dest_name in _MAP:
36
+ src = repo_root / src_name
37
+ if src.is_dir():
38
+ shutil.copytree(src, dest_root / dest_name)
39
+ super().run()
40
+
41
+
42
+ setup(cmdclass={"build_py": build_py_with_assets})
@@ -0,0 +1,90 @@
1
+ """Tests for `oks init` — instance scaffolding + shareable-asset materialization."""
2
+ from pathlib import Path
3
+
4
+ from typer.testing import CliRunner
5
+
6
+ from knowledge_studio.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_init_scaffolds_buckets_and_data_gitignore(tmp_path):
12
+ target = tmp_path / "kb"
13
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
14
+ assert result.exit_code == 0, result.output
15
+
16
+ for d in ["wiki", "drafts", "raw", "profiles/goals", "settings", "_meta", "templates"]:
17
+ assert (target / d).is_dir(), f"missing bucket {d}"
18
+
19
+ gi = (target / ".gitignore").read_text(encoding="utf-8")
20
+ # instance gitignore ignores only per-machine state, and TRACKS memory
21
+ assert ".oks/" in gi
22
+ assert "wiki/**/*.md" not in gi
23
+ assert "drafts/*.md" not in gi
24
+
25
+
26
+ def test_init_materializes_shareable_assets(tmp_path):
27
+ target = tmp_path / "kb"
28
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
29
+ assert result.exit_code == 0, result.output
30
+
31
+ # skills + templates arrive so the Claude Code experience works out of the box
32
+ assert (target / ".claude" / "skills" / "ingest").is_dir()
33
+ assert (target / ".claude" / "settings.json").is_file()
34
+ assert (target / "templates").is_dir()
35
+
36
+
37
+ def test_init_upgrade_refreshes_assets_but_keeps_user_files(tmp_path):
38
+ target = tmp_path / "kb"
39
+ runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
40
+
41
+ marker = target / ".claude" / "MARKER.txt"
42
+ marker.write_text("local edit", encoding="utf-8")
43
+
44
+ bundled = target / ".claude" / "settings.json"
45
+ original = bundled.read_text(encoding="utf-8")
46
+ bundled.write_text("{}", encoding="utf-8")
47
+
48
+ # re-init without --upgrade keeps existing assets untouched
49
+ runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
50
+ assert marker.exists()
51
+ assert bundled.read_text(encoding="utf-8") == "{}"
52
+
53
+ # --upgrade merge-copies bundled assets: bundled files refreshed,
54
+ # user-owned files (marker) survive — no more whole-tree deletion
55
+ runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default", "--upgrade"])
56
+ assert marker.exists()
57
+ assert bundled.read_text(encoding="utf-8") == original
58
+
59
+
60
+ def test_init_requires_path_argument():
61
+ result = runner.invoke(app, ["init"])
62
+ assert result.exit_code != 0
63
+
64
+
65
+ def test_init_aborts_on_nonempty_non_kb_dir(tmp_path):
66
+ target = tmp_path / "documents"
67
+ target.mkdir()
68
+ (target / "important.txt").write_text("do not touch", encoding="utf-8")
69
+
70
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
71
+ assert result.exit_code == 1
72
+ assert not (target / "wiki").exists()
73
+
74
+ # --force overrides the guard
75
+ result = runner.invoke(
76
+ app, ["init", str(target), "--no-git", "--no-set-default", "--force"]
77
+ )
78
+ assert result.exit_code == 0, result.output
79
+ assert (target / "wiki").is_dir()
80
+ assert (target / "important.txt").exists()
81
+
82
+
83
+ def test_init_rerun_on_existing_kb_is_idempotent(tmp_path):
84
+ target = tmp_path / "kb"
85
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
86
+ assert result.exit_code == 0, result.output
87
+
88
+ # target now contains wiki/ → treated as an existing KB, no --force needed
89
+ result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
90
+ assert result.exit_code == 0, result.output
@@ -1,50 +0,0 @@
1
- """Tests for `oks init` — instance scaffolding + shareable-asset materialization."""
2
- from pathlib import Path
3
-
4
- from typer.testing import CliRunner
5
-
6
- from knowledge_studio.cli import app
7
-
8
- runner = CliRunner()
9
-
10
-
11
- def test_init_scaffolds_buckets_and_data_gitignore(tmp_path):
12
- target = tmp_path / "kb"
13
- result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
14
- assert result.exit_code == 0, result.output
15
-
16
- for d in ["wiki", "drafts", "raw", "profiles/goals", "settings", "_meta", "templates"]:
17
- assert (target / d).is_dir(), f"missing bucket {d}"
18
-
19
- gi = (target / ".gitignore").read_text(encoding="utf-8")
20
- # instance gitignore ignores only per-machine state, and TRACKS memory
21
- assert ".oks/" in gi
22
- assert "wiki/**/*.md" not in gi
23
- assert "drafts/*.md" not in gi
24
-
25
-
26
- def test_init_materializes_shareable_assets(tmp_path):
27
- target = tmp_path / "kb"
28
- result = runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
29
- assert result.exit_code == 0, result.output
30
-
31
- # skills + templates arrive so the Claude Code experience works out of the box
32
- assert (target / ".claude" / "skills" / "ingest").is_dir()
33
- assert (target / ".claude" / "settings.json").is_file()
34
- assert (target / "templates").is_dir()
35
-
36
-
37
- def test_init_upgrade_refreshes_assets(tmp_path):
38
- target = tmp_path / "kb"
39
- runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
40
-
41
- marker = target / ".claude" / "MARKER.txt"
42
- marker.write_text("local edit", encoding="utf-8")
43
-
44
- # re-init without --upgrade keeps existing assets (marker survives)
45
- runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default"])
46
- assert marker.exists()
47
-
48
- # --upgrade re-copies bundled assets, dropping the local marker
49
- runner.invoke(app, ["init", str(target), "--no-git", "--no-set-default", "--upgrade"])
50
- assert not marker.exists()