Skip to content

Commit 1d565c1

Browse files
committed
Merge branch 'main' of github.com:NickCrew/claude-cortex
2 parents 6e44757 + 22988fe commit 1d565c1

20 files changed

Lines changed: 1820 additions & 146 deletions

‎claude_ctx_py/cli.py‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,20 +2119,20 @@ def _handle_memories_cross_command(args: argparse.Namespace) -> int:
21192119
elif sort_by == "size":
21202120
files.sort(key=lambda pair: pair[0].stat().st_size, reverse=True)
21212121

2122-
_print(f"[0;34mProject memories across {_CLAUDE_PROJECTS_DIR}:[0m")
2123-
_print(f"[0;33m{len(files)} files found[0m")
2122+
_print(f"Project memories across {_CLAUDE_PROJECTS_DIR}:")
2123+
_print(f"{len(files)} files found")
21242124
_print("")
21252125

21262126
current_group = None
21272127
for real, vpath in files:
21282128
group = vpath.split("/", 1)[0]
21292129
if group != current_group:
21302130
current_group = group
2131-
_print(f"[0;36m{group}/[0m")
2131+
_print(f"{group}/")
21322132
stat = real.stat()
21332133
mtime = datetime.fromtimestamp(stat.st_mtime)
21342134
size_kb = stat.st_size / 1024
2135-
_print(f" [0;32m{Path(vpath).name}[0m")
2135+
_print(f" {Path(vpath).name}")
21362136
_print(f" Path: {vpath}")
21372137
_print(f" Modified: {mtime.strftime('%Y-%m-%d %H:%M')}")
21382138
_print(f" Size: {size_kb:.1f}KB")
@@ -2210,11 +2210,11 @@ def _handle_memories_cross_command(args: argparse.Namespace) -> int:
22102210
_print(f"No results found for '{args.query}'")
22112211
return 0
22122212

2213-
_print(f"[0;34mSearch results for '{args.query}':[0m")
2214-
_print(f"[0;33mFound {len(results)} matches[0m")
2213+
_print(f"Search results for '{args.query}':")
2214+
_print(f"Found {len(results)} matches")
22152215
_print("")
22162216
for r in results:
2217-
_print(f"[0;32m{r['file']}[0m:[0;33m{r['line']}[0m")
2217+
_print(f"{r['file']}:{r['line']}")
22182218
_print(f" {r['content'][:100]}")
22192219
_print("")
22202220
return 0
@@ -2329,8 +2329,8 @@ def _docs_dir() -> Path:
23292329
md_files.sort(key=lambda p: p.stat().st_size, reverse=True)
23302330

23312331
# Print header
2332-
_print(f"[0;34mDocumentation in {docs_dir}:[0m")
2333-
_print(f"[0;33m{len(md_files)} files found[0m")
2332+
_print(f"Documentation in {docs_dir}:")
2333+
_print(f"{len(md_files)} files found")
23342334
_print("")
23352335

23362336
# Group by directory for better readability
@@ -2343,13 +2343,13 @@ def _docs_dir() -> Path:
23432343
if file_dir != current_dir:
23442344
current_dir = file_dir
23452345
if str(file_dir) != ".":
2346-
_print(f"[0;36m{file_dir}/[0m")
2346+
_print(f"{file_dir}/")
23472347

23482348
stat = md_file.stat()
23492349
mtime = datetime.fromtimestamp(stat.st_mtime)
23502350
size_kb = stat.st_size / 1024
23512351

2352-
_print(f" [0;32m{rel_path.name}[0m")
2352+
_print(f" {rel_path.name}")
23532353
_print(f" Path: {rel_path}")
23542354
_print(f" Modified: {mtime.strftime('%Y-%m-%d %H:%M')}")
23552355
_print(f" Size: {size_kb:.1f}KB")
@@ -2453,12 +2453,12 @@ def add_to_tree(parent_tree, parent_path):
24532453
_print(f"No results found for '{query}'")
24542454
return 0
24552455

2456-
_print(f"[0;34mSearch results for '{query}':[0m")
2457-
_print(f"[0;33mFound {len(results)} matches[0m")
2456+
_print(f"Search results for '{query}':")
2457+
_print(f"Found {len(results)} matches")
24582458
_print("")
24592459

24602460
for result in results:
2461-
_print(f"[0;32m{result['file']}[0m:[0;33m{result['line']}[0m")
2461+
_print(f"{result['file']}:{result['line']}")
24622462
_print(f" {result['content'][:100]}")
24632463
_print("")
24642464

@@ -2519,18 +2519,18 @@ def add_to_tree(parent_tree, parent_path):
25192519
bookmarks[name] = rel_path
25202520
_save_bookmarks(bookmarks)
25212521

2522-
_print(f"[0;32m✓ Added bookmark '[0;33m{name}[0;32m' -> {rel_path}[0m")
2522+
_print(f"✓ Added bookmark '{name}' -> {rel_path}")
25232523
return 0
25242524

25252525
if args.bookmark_command == "list":
25262526
if not bookmarks:
25272527
_print("No bookmarks saved")
25282528
return 0
25292529

2530-
_print("[0;34mSaved bookmarks:[0m")
2530+
_print("Saved bookmarks:")
25312531
_print("")
25322532
for name, path in sorted(bookmarks.items()):
2533-
_print(f" [0;33m{name}[0m")
2533+
_print(f" {name}")
25342534
_print(f" {path}")
25352535
return 0
25362536

@@ -2542,7 +2542,7 @@ def add_to_tree(parent_tree, parent_path):
25422542

25432543
del bookmarks[name]
25442544
_save_bookmarks(bookmarks)
2545-
_print(f"[0;32m✓ Removed bookmark '{name}'[0m")
2545+
_print(f"✓ Removed bookmark '{name}'")
25462546
return 0
25472547

25482548
if args.bookmark_command == "view":
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Output style management.
2+
3+
Output styles are markdown files (with YAML front matter) that Claude Code
4+
loads from ``<claude-dir>/output-styles/`` and applies by name via the
5+
``outputStyle`` key in ``settings.json``.
6+
7+
This module manages them the same way the rest of Cortex manages assets:
8+
9+
- **discover** the styles shipped in the repo's ``output-styles/`` directory,
10+
- **install / uninstall** = create / remove a symlink in
11+
``<claude-dir>/output-styles/`` (mirrors ``asset_installer._install_rule``),
12+
- **activate / deactivate** = write the ``outputStyle`` key in ``settings.json``
13+
(reuses ``hooks.load_settings`` / ``hooks.save_settings``).
14+
15+
The active style is stored as a flat string (e.g. ``"Engineering"``) matching
16+
how Claude Code stores the builtin styles; ``"default"`` means no custom style.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from dataclasses import dataclass
22+
from pathlib import Path
23+
from typing import List, Optional, Tuple
24+
25+
from .base import (
26+
_clean_scalar,
27+
_extract_front_matter,
28+
_resolve_claude_dir,
29+
_resolve_cortex_root,
30+
_tokenize_front_matter,
31+
)
32+
from .hooks import load_settings, save_settings
33+
34+
OUTPUT_STYLES_DIRNAME = "output-styles"
35+
DEFAULT_STYLE = "default"
36+
_SETTINGS_KEY = "outputStyle"
37+
38+
39+
@dataclass
40+
class OutputStyle:
41+
"""A discovered output style and its install/active state."""
42+
43+
slug: str # filename stem, e.g. "engineering"
44+
name: str # front-matter name, e.g. "Engineering" (value written to settings)
45+
description: str
46+
source_path: Path
47+
installed: bool = False
48+
active: bool = False
49+
50+
51+
def _source_dir() -> Path:
52+
"""Repo directory that ships the output styles."""
53+
return _resolve_cortex_root() / OUTPUT_STYLES_DIRNAME
54+
55+
56+
def _target_dir() -> Path:
57+
"""Resolved ``<claude-dir>/output-styles`` install location."""
58+
return _resolve_claude_dir() / OUTPUT_STYLES_DIRNAME
59+
60+
61+
def _parse_style_metadata(path: Path) -> Tuple[str, str]:
62+
"""Return ``(name, description)`` from a style file's front matter.
63+
64+
Falls back to the filename stem for ``name`` and an empty description when
65+
the file is unreadable or has no front matter.
66+
"""
67+
name = path.stem
68+
description = ""
69+
try:
70+
text = path.read_text(encoding="utf-8")
71+
except OSError:
72+
return name, description
73+
74+
front_matter = _extract_front_matter(text)
75+
if not front_matter:
76+
return name, description
77+
78+
for indent, line in _tokenize_front_matter(front_matter.splitlines()):
79+
if indent != 0 or ":" not in line:
80+
continue
81+
key, _, value = line.partition(":")
82+
key = key.strip().lower()
83+
if key == "name":
84+
cleaned = _clean_scalar(value)
85+
if cleaned:
86+
name = cleaned
87+
elif key == "description":
88+
description = _clean_scalar(value)
89+
return name, description
90+
91+
92+
def _is_installed(link: Path, source_path: Path) -> bool:
93+
"""True when ``link`` is our symlink to ``source_path`` (or a real file)."""
94+
try:
95+
if link.is_symlink():
96+
return link.resolve() == source_path.resolve()
97+
return link.is_file()
98+
except OSError:
99+
return False
100+
101+
102+
def get_active_output_style() -> str:
103+
"""Return the currently active style name, or ``"default"``."""
104+
value = load_settings().get(_SETTINGS_KEY, DEFAULT_STYLE)
105+
return value if isinstance(value, str) else DEFAULT_STYLE
106+
107+
108+
def list_output_styles() -> List[OutputStyle]:
109+
"""Discover output styles in the repo, with install/active state resolved."""
110+
source_dir = _source_dir()
111+
if not source_dir.is_dir():
112+
return []
113+
114+
target_dir = _target_dir()
115+
active = get_active_output_style()
116+
styles: List[OutputStyle] = []
117+
for md in sorted(source_dir.glob("*.md")):
118+
name, description = _parse_style_metadata(md)
119+
link = target_dir / md.name
120+
styles.append(
121+
OutputStyle(
122+
slug=md.stem,
123+
name=name,
124+
description=description,
125+
source_path=md,
126+
installed=_is_installed(link, md),
127+
active=(active == name),
128+
)
129+
)
130+
return styles
131+
132+
133+
def get_output_style(slug: str) -> Optional[OutputStyle]:
134+
"""Return the discovered style with the given slug, if any."""
135+
for style in list_output_styles():
136+
if style.slug == slug:
137+
return style
138+
return None
139+
140+
141+
def install_output_style(slug: str) -> Tuple[int, str]:
142+
"""Symlink a style file into ``<claude-dir>/output-styles`` (idempotent)."""
143+
source = _source_dir() / f"{slug}.md"
144+
if not source.is_file():
145+
return 1, f"Output style not found: {slug}"
146+
147+
target_dir = _target_dir()
148+
try:
149+
target_dir.mkdir(parents=True, exist_ok=True)
150+
link = target_dir / source.name
151+
# Re-point stale links / replace files (matches the link-repair pattern).
152+
if link.is_symlink() or link.exists():
153+
link.unlink()
154+
link.symlink_to(source)
155+
except OSError as e:
156+
return 1, f"Failed to install output style: {e}"
157+
return 0, f"Installed output style: {slug}"
158+
159+
160+
def uninstall_output_style(slug: str) -> Tuple[int, str]:
161+
"""Remove the style symlink; reset to default if it was the active style."""
162+
source = _source_dir() / f"{slug}.md"
163+
link = _target_dir() / f"{slug}.md"
164+
if not link.is_symlink() and not link.exists():
165+
return 1, f"Output style not installed: {slug}"
166+
167+
name = _parse_style_metadata(source)[0] if source.is_file() else slug
168+
try:
169+
link.unlink()
170+
except OSError as e:
171+
return 1, f"Failed to uninstall output style: {e}"
172+
173+
# Don't leave settings.json pointing at a style we just removed.
174+
if get_active_output_style() == name:
175+
deactivate_output_style()
176+
return 0, f"Uninstalled output style: {slug}"
177+
178+
179+
def activate_output_style(slug: str) -> Tuple[int, str]:
180+
"""Set ``outputStyle`` to the style's name, installing it first if needed."""
181+
source = _source_dir() / f"{slug}.md"
182+
if not source.is_file():
183+
return 1, f"Output style not found: {slug}"
184+
185+
# Claude Code can only apply a style it can find on disk.
186+
link = _target_dir() / source.name
187+
if not _is_installed(link, source):
188+
code, message = install_output_style(slug)
189+
if code != 0:
190+
return code, message
191+
192+
name = _parse_style_metadata(source)[0]
193+
settings = load_settings()
194+
settings[_SETTINGS_KEY] = name
195+
ok, message = save_settings(settings)
196+
if not ok:
197+
return 1, message
198+
return 0, f"Activated output style: {name}"
199+
200+
201+
def deactivate_output_style() -> Tuple[int, str]:
202+
"""Reset ``outputStyle`` to ``"default"``."""
203+
settings = load_settings()
204+
settings[_SETTINGS_KEY] = DEFAULT_STYLE
205+
ok, message = save_settings(settings)
206+
if not ok:
207+
return 1, message
208+
return 0, "Reset output style to default"

0 commit comments

Comments
 (0)