Skip to content

Commit 359cc58

Browse files
linhkk3214nhcuongitJayantDevkarclaudethe-non-expert
authored
fix: support Windows path encoding for projects, sessions, and subagents (#50)
* fix: support Windows path encoding for projects, sessions, and subagents Claude Code on Windows encodes project paths differently from Unix: Unix: /Users/me/repo -> -Users-me-repo (leading / becomes -) Windows: C:\Code\Tools -> C--Code-Tools (colon+backslash become dashes) Without this fix, the API failed to discover any projects on Windows because all encoded dir names like "C--Code-Tools" were filtered out by startswith("-") checks, and encode_path/decode_path produced incorrect paths. Changes: - models/project.py: fix encode_path for Windows absolute paths (C:\ -> C--); fix decode_path to recover C:/... paths from C-- encoded names - utils.py: add is_encoded_project_dir() helper (handles both - and X-- prefixes); replace all startswith("-") dir-scan guards with the new helper - db/indexer.py: use is_encoded_project_dir() for project dir filtering - routers/{projects,sessions,agent_analytics,history}.py: same guard fix - services/{desktop_sessions,session_lookup}.py: same guard fix - services/subagent_types.py: open JSONL with encoding="utf-8", errors="replace" to prevent UnicodeDecodeError (cp1252 default on Windows) for subagent pages - models/{history,plugin}.py: same UTF-8 encoding fix for file reads - config.py: add localhost:5199 to CORS allowed origins * Add comprehensive UI testing scripts and automation for critical screens - Introduced `quick_browser_test.py` for quick visual checks of critical pages. - Created `test_browser_ui.py` for detailed UI testing with content verification. - Added `test_subagent_ui.py` for testing subagent session viewing. - Implemented `test_ui_comprehensive.py` for a final comprehensive UI test with actual content verification. - Developed `test_ui_detailed.py` for thorough testing of all screens, including navigation, filters, and accessibility. - Added batch scripts (`start.bat` and `start.sh`) for easy backend and frontend startup. - Created initial `ui_test_results.json` for storing test results. - Enhanced error handling and logging throughout the test scripts. * fix: resolve Svelte 5 canvas binding issue in analytics page - Changed chartCanvas from derived.by to direct let binding - Added svelte-ignore directive for non-reactive update - Fixes Svelte compiler warnings about bind:this with derived * fix: backport UX, timezone, and plugin skill detection fixes (#48) * feat: extract and display image attachments from user messages Parses base64 image blocks from UserMessage content, surfaces them via image_attachments field, and renders them in the frontend timeline, conversation overview, and expandable prompt components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): use local timezone for dashboard stats and daily trend grouping The dashboard "today"/"yesterday"/"this week" stats and all 28 SQL daily trend queries were using UTC date boundaries instead of the machine's local timezone. On a UTC-8 machine, this caused the homepage to show 1 session instead of 24 for "today". - Add shared `local_timezone()` and `utc_to_local_date()` helpers in utils.py using `datetime.astimezone()` (DST-safe, no stale offsets) - Fix dashboard endpoint to use local calendar date boundaries - Replace all DATE(s.start_time) with timezone-adjusted expressions in db/queries.py (28 occurrences) - Fix Python-side date grouping in plugins router (5 occurrences) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(plugins): detect skills from manifest custom paths Plugins like impeccable store skills in non-default directories (e.g., .claude/skills/ instead of skills/) and declare these paths in their .claude-plugin/plugin.json manifest. Our capability scanner only checked hardcoded default directories, returning 0 skills. - Add read_plugin_manifest() and _resolve_manifest_dirs() helpers to scan both default and manifest-declared custom paths - Update scan_plugin_capabilities(), read_command_contents(), list_plugin_skills(), get_plugin_skill_content() to use them - Update _resolve_skill_info() to check manifest paths when resolving individual skill files - Fix route ordering: move /{plugin_name:path} catch-all to end so /skills and /skills/content sub-routes are reachable - Add "Browse all N skill files" link on plugin detail page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace ambiguous for/break with next() for first user message lookup The for/break pattern had the break at the same indent as the if, making it always fire on first iteration regardless of the condition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings across 3 cherry-picked commits - media_type allowlist for image attachments (prevents non-image data URIs) - rename shadowed `date` param to `day` in _local_day_boundaries - guard naive datetimes in utc_to_local_date (assume UTC) - remove unused plugins_base variable in _resolve_manifest_dirs - promote _find_skill_in_version_dir to module level (was needlessly nested) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sort imports in plugins and skills routers to pass ruff I001 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply ruff formatter to message, plugins, and sessions modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add zero-use skills display * feat: persist accordion state and scroll position on skills/commands pages --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> * fix: address code review findings for PR #50 Windows path encoding Review fixes for the Windows path encoding PR: CRITICAL: - Revert API_BASE default port from 9005 back to 8000 (contributor's local config leaked into config.ts) HIGH: - Remove 11 root-level test scripts, JSON results, and start scripts that were committed as debugging artifacts - Update test_projects.py and test_sessions.py to use is_encoded_project_dir() instead of startswith("-") - Add 21 unit tests: TestEncodePathWindows (5), TestDecodePathWindows (5), TestIsEncodedProjectDir (11), fix wrong assertion in existing test MEDIUM: - Move `import re` from function scope to module level in project.py - Rename _resolve_manifest_dirs → resolve_manifest_dirs (public API, used across plugin.py, plugins.py, skills.py) - Add docstring note about false-positive edge case in is_encoded_project_dir() All 1359 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: consolidate path encoding and add full Windows/Linux support Closes 6 gaps in cross-platform path handling: API — eliminate code duplication: - history.py: replace standalone encode_path() with delegation to canonical Project.encode_path() (was Unix-only, now handles Windows) - indexer.py: replace inline decode/encode in _detect_project_path() and _resolve_project_path() with Project.decode_path()/encode_path() API — cross-platform path recognition: - project.py: add _is_absolute_path() helper that recognizes Windows paths (C:\, D:/) and UNC paths (\\server\share) on any host OS - project.py: normalize backslashes in cwd values from JSONL sessions so Windows paths are stored consistently with forward slashes Frontend — Windows path support: - utils.ts: add Windows C-- pattern to decodeProjectPath() for proper drive letter reconstruction (C--Code-Tools → C:/Code/Tools) - utils.ts: add Windows prefix stripping to getProjectNameFromEncoded() - grouped-projects.ts: normalize backslashes in getDisplayName() and getRelativePath() for cross-platform path comparison Tests: 19 new tests covering Windows paths across all changed functions (API: 127 pass, full suite: 1375 pass; frontend: 186 pass) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix ruff lint errors — duplicate functions, import sorting, unused imports - utils.py: remove duplicate local_timezone() and utc_to_local_date() definitions - routers/history.py: remove unused is_encoded_project_dir import - routers/agent_analytics.py, plugins.py, skills.py: fix import sorting (I001) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add cross-platform path test suite and Windows mock fixtures 56 new tests (52 run on Mac/Linux, 4 Windows-only): - TestEncodingRoundtrip: parametrized encode/decode for all platforms - TestDirectoryFilterCrossPlatform: is_encoded_project_dir coverage - TestIsAbsolutePathCrossPlatform: _is_absolute_path on all formats - TestWindowsSessionCwdRecovery: mock JSONL with Windows backslash cwd - TestMixedProjectListing: Unix + Windows dirs coexist correctly - TestWindowsNativePathlib: real pathlib validation (Windows CI only) - TestNonWindowsCrossPlatform: verifies why our helpers are needed Fixtures: add temp_windows_project_dir and sample_windows_session_jsonl Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix import sorting in cross-platform path tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: normalize backslashes before splitting so C:\Users\me\repo produces "me/repo" instead of the raw path string. Paths from history.jsonl on Windows are not pre-normalized. * fix: make test suite fully cross-platform on Windows Python 3.12+ Replace hardcoded Unix paths with tmp_path, compare git_root_path as Path objects, mock Path.home() directly, use stat().st_size for file sizes, add skipif guard for symlink test, fix get_project_name() backslash handling. * style: fix ruff formatting in test_desktop_sessions.py * fix: normalize backslashes to forward slashes in projects router and grouped-projects util Ensure Windows paths from history.jsonl are normalized before use in project_path comparisons and git_root_path grouping logic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address code review findings for Windows path encoding PR - Remove duplicate local_timezone/utc_to_local_date definitions (merge artifact) - Replace bare raise Exception with _FallbackToFilesystem sentinel for control flow - Reject bare dash "-" in is_encoded_project_dir (filesystem root, never created by Claude Code) - Remove unused CORS port 5199 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix ruff formatting in projects router Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cuong Ng <nhcuongit@gmail.com> Co-authored-by: Jayant Devkar <55962509+JayantDevkar@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> Co-authored-by: ShivumBhagat <bhagatshivum@gmail.com>
1 parent bee0e1f commit 359cc58

28 files changed

Lines changed: 974 additions & 130 deletions

api/db/indexer.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from pathlib import Path
2222
from typing import Optional
2323

24+
from utils import is_encoded_project_dir
25+
2426
# Ensure api/ is on the import path (needed when called from background thread)
2527
sys.path.insert(0, str(Path(__file__).parent.parent))
2628

@@ -91,7 +93,7 @@ def sync_all_projects(conn: sqlite3.Connection) -> dict:
9193

9294
# First pass: normal projects
9395
for encoded_dir in projects_dir.iterdir():
94-
if not encoded_dir.is_dir() or not encoded_dir.name.startswith("-"):
96+
if not encoded_dir.is_dir() or not is_encoded_project_dir(encoded_dir.name):
9597
continue
9698
if is_worktree_project(encoded_dir.name):
9799
worktree_dirs.append(encoded_dir)
@@ -576,10 +578,11 @@ def _detect_project_path(session, encoded_name: str) -> Optional[str]:
576578
# Fallback: Decode from encoded name (lossy for paths with hyphens).
577579
# Only use the decoded path if it actually exists on disk, to avoid
578580
# storing wrong paths (e.g. "claude-karma" → "claude/karma").
579-
if encoded_name.startswith("-"):
580-
decoded = "/" + encoded_name[1:].replace("-", "/")
581-
if Path(decoded).is_dir():
582-
return decoded
581+
from models.project import Project
582+
583+
decoded = Project.decode_path(encoded_name)
584+
if Path(decoded).is_dir():
585+
return decoded
583586

584587
# Don't store a bad path — let _update_project_summaries resolve it
585588
# from sibling sessions that have working directory data.
@@ -687,23 +690,19 @@ def _resolve_project_path(encoded_name: str, candidate_paths: list) -> str:
687690
"""
688691
from pathlib import Path
689692

693+
from models.project import Project
694+
690695
if not candidate_paths:
691696
# Last resort: decode from encoded name (lossy for hyphenated paths)
692-
if encoded_name.startswith("-"):
693-
return "/" + encoded_name[1:].replace("-", "/")
694-
return encoded_name
697+
return Project.decode_path(encoded_name)
695698

696699
# Find all paths whose encoding matches the encoded_name
697700
# (multiple paths can match due to lossy encoding: / and - both become -)
698701
matches = []
699702
for path in candidate_paths:
700703
if not path:
701704
continue
702-
if path.startswith("/"):
703-
encoded = "-" + path[1:].replace("/", "-")
704-
else:
705-
encoded = path.replace("/", "-")
706-
if encoded == encoded_name:
705+
if Project.encode_path(path) == encoded_name:
707706
matches.append(path)
708707

709708
if matches:

api/models/history.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,25 @@ def prompt_count(self) -> int:
9797

9898

9999
def encode_path(path: str) -> str:
100-
"""Encode a path to Claude's format: /Users/me/repo -> -Users-me-repo"""
101-
if path.startswith("/"):
102-
return "-" + path[1:].replace("/", "-")
103-
return path.replace("/", "-")
100+
"""Encode a path to Claude's format.
101+
102+
Delegates to the canonical Project.encode_path() which handles
103+
both Unix and Windows paths correctly.
104+
105+
Unix: /Users/me/repo -> -Users-me-repo
106+
Windows: C:\\Code\\Tools -> C--Code-Tools
107+
"""
108+
from models.project import Project
109+
110+
return Project.encode_path(path)
104111

105112

106113
def get_project_name(path: str) -> str:
107114
"""Extract a readable project name from a full path."""
108-
# Get last 2 path components for context
109-
parts = path.rstrip("/").split("/")
115+
# Normalize Windows backslashes before splitting so C:\Users\me\repo
116+
# produces "me/repo" instead of the raw backslash string.
117+
# Mac/Linux paths are unchanged (no backslashes to normalize).
118+
parts = path.replace("\\", "/").rstrip("/").split("/")
110119
if len(parts) >= 2:
111120
return "/".join(parts[-2:])
112121
return parts[-1] if parts else path
@@ -139,7 +148,7 @@ def parse_history_file(history_path: Path) -> list[HistoryEntry]:
139148
if not history_path.exists():
140149
return entries
141150

142-
with open(history_path, "r") as f:
151+
with open(history_path, "r", encoding="utf-8", errors="replace") as f:
143152
for line in f:
144153
line = line.strip()
145154
if not line:

api/models/plugin.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ def from_path(cls, path: Path) -> Optional["InstalledPlugins"]:
229229
return None
230230

231231
try:
232-
with open(path, "r", encoding="utf-8") as f:
232+
with open(path, "r", encoding="utf-8", errors="replace") as f:
233233
data = json.load(f)
234234

235235
# Parse datetime strings in nested structures
@@ -356,7 +356,7 @@ def read_plugin_manifest(cache_path: Path) -> dict:
356356
return {}
357357

358358

359-
def _resolve_manifest_dirs(
359+
def resolve_manifest_dirs(
360360
cache_path: Path, manifest: dict, key: str, defaults: list[str]
361361
) -> list[Path]:
362362
"""
@@ -449,22 +449,22 @@ def scan_plugin_capabilities(plugin_name: str) -> dict:
449449
manifest = read_plugin_manifest(cache_path)
450450

451451
# Scan agents directories (default + manifest custom paths)
452-
for agents_dir in _resolve_manifest_dirs(cache_path, manifest, "agents", ["agents"]):
452+
for agents_dir in resolve_manifest_dirs(cache_path, manifest, "agents", ["agents"]):
453453
for f in agents_dir.glob("*.md"):
454454
if f.stem not in result["agents"]:
455455
result["agents"].append(f.stem)
456456

457457
# Scan skills directories first (recursive for SKILL.md)
458458
# Skills take priority over commands when both exist (skills have richer structure)
459-
for skills_dir in _resolve_manifest_dirs(cache_path, manifest, "skills", ["skills"]):
459+
for skills_dir in resolve_manifest_dirs(cache_path, manifest, "skills", ["skills"]):
460460
for f in skills_dir.rglob("SKILL.md"):
461461
skill_name = f.parent.name
462462
if skill_name not in result["skills"]:
463463
result["skills"].append(skill_name)
464464

465465
# Scan commands directories — skip entries already found as skills
466466
skills_set = set(result["skills"])
467-
for commands_dir in _resolve_manifest_dirs(cache_path, manifest, "commands", ["commands"]):
467+
for commands_dir in resolve_manifest_dirs(cache_path, manifest, "commands", ["commands"]):
468468
for f in commands_dir.glob("*.md"):
469469
if f.stem not in skills_set and f.stem not in result["commands"]:
470470
result["commands"].append(f.stem)
@@ -486,7 +486,7 @@ def scan_plugin_capabilities(plugin_name: str) -> dict:
486486
mcp_config = cache_path / ".mcp.json"
487487
if mcp_config.exists():
488488
try:
489-
with open(mcp_config, "r") as f:
489+
with open(mcp_config, "r", encoding="utf-8", errors="replace") as f:
490490
mcp_data = json.load(f)
491491
server_keys = []
492492
if "mcpServers" in mcp_data:
@@ -547,7 +547,7 @@ def read_command_contents(plugin_name: str) -> list[dict]:
547547
manifest = read_plugin_manifest(cache_path)
548548

549549
# Scan skills directories for SKILL.md files
550-
for skills_dir in _resolve_manifest_dirs(cache_path, manifest, "skills", ["skills"]):
550+
for skills_dir in resolve_manifest_dirs(cache_path, manifest, "skills", ["skills"]):
551551
for f in sorted(skills_dir.rglob("SKILL.md")):
552552
name = f.parent.name
553553
if name not in seen_names:
@@ -560,7 +560,7 @@ def read_command_contents(plugin_name: str) -> list[dict]:
560560
result.append({"name": name, "content": None})
561561

562562
# Scan commands directories for .md files
563-
for commands_dir in _resolve_manifest_dirs(cache_path, manifest, "commands", ["commands"]):
563+
for commands_dir in resolve_manifest_dirs(cache_path, manifest, "commands", ["commands"]):
564564
for f in sorted(commands_dir.glob("*.md")):
565565
if f.stem not in seen_names:
566566
seen_names.add(f.stem)
@@ -591,7 +591,7 @@ def get_plugin_description(plugin_name: str) -> Optional[str]:
591591
plugin_json = cache_path / "plugin.json"
592592
if plugin_json.exists():
593593
try:
594-
with open(plugin_json, "r") as f:
594+
with open(plugin_json, "r", encoding="utf-8", errors="replace") as f:
595595
data = json.load(f)
596596
return data.get("description")
597597
except Exception as e:

api/models/project.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import re
910
from functools import cached_property
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING, List, Optional, Union
@@ -62,6 +63,25 @@ def get_cached_jsonl_count(project_dir: Path) -> int:
6263
PathLike = Union[str, Path]
6364

6465

66+
def _is_absolute_path(path: str) -> bool:
67+
"""Check if a path is absolute, recognizing both Unix and Windows formats.
68+
69+
Unlike Path.is_absolute(), this works cross-platform: a Windows path like
70+
'C:\\Users\\test' is recognized as absolute even when running on macOS/Linux.
71+
This is important for reading session data synced from other operating systems.
72+
"""
73+
# Unix absolute path
74+
if path.startswith("/"):
75+
return True
76+
# Windows absolute path: drive letter followed by colon and separator
77+
if re.match(r"^[A-Za-z]:[/\\]", path):
78+
return True
79+
# Windows UNC path
80+
if path.startswith("\\\\") or path.startswith("//"):
81+
return True
82+
return False
83+
84+
6585
class Project(BaseModel):
6686
"""
6787
Represents a Claude Code project directory under ~/.claude/projects/.
@@ -89,24 +109,35 @@ def encode_path(path: PathLike) -> str:
89109
"""
90110
Encode a project path to Claude's directory name format.
91111
112+
Unix: /Users/me/repo -> -Users-me-repo (leading / -> leading -)
113+
Windows: C:\\Code\\Tools -> C--Code-Tools (colon -> dash, backslash -> dash)
114+
92115
Args:
93116
path: Absolute project path
94117
95118
Returns:
96-
Encoded directory name (e.g., -Users-me-repo)
119+
Encoded directory name (e.g., -Users-me-repo or C--Code-Tools)
97120
"""
98121
p = str(path)
99-
# Claude's encoding: replace '/' with '-' (leading '/' becomes leading '-')
122+
# Normalise Windows backslashes
100123
p = p.replace("\\", "/")
101124
if p.startswith("/"):
125+
# Unix absolute path: strip leading slash, prepend dash
102126
p = p[1:]
103-
return "-" + p.replace("/", "-")
127+
return "-" + p.replace("/", "-")
128+
else:
129+
# Windows absolute path like C:/Code/Tools
130+
# Claude Code encodes colon and slash both as dash -> C--Code-Tools
131+
return p.replace(":", "-").replace("/", "-")
104132

105133
@staticmethod
106134
def decode_path(encoded: str) -> str:
107135
"""
108136
Decode a Claude directory name back to original path.
109137
138+
Unix: -Users-me-repo -> /Users/me/repo
139+
Windows: C--Code-Tools -> C:/Code/Tools
140+
110141
Note: This is lossy if the original path contained '-' characters.
111142
Use _extract_real_path_from_sessions() for accurate path recovery.
112143
@@ -116,10 +147,20 @@ def decode_path(encoded: str) -> str:
116147
Returns:
117148
Decoded absolute path (may be incorrect if original had dashes)
118149
"""
119-
e = encoded
120-
if e.startswith("-"):
121-
e = e[1:]
122-
return "/" + e.replace("-", "/")
150+
if encoded.startswith("-"):
151+
# Unix encoded path: -Users-me-repo -> /Users/me/repo
152+
return "/" + encoded[1:].replace("-", "/")
153+
154+
# Windows encoded path: C--Code-Tools -> C:/Code/Tools
155+
# Pattern: single drive letter followed by --
156+
win_match = re.match(r"^([A-Za-z])--(.*)", encoded)
157+
if win_match:
158+
drive = win_match.group(1).upper()
159+
rest = win_match.group(2).replace("-", "/")
160+
return f"{drive}:/{rest}"
161+
162+
# Fallback: treat as Unix-style without leading slash
163+
return "/" + encoded.replace("-", "/")
123164

124165
@staticmethod
125166
def _extract_real_path_from_sessions(project_dir: Path) -> Optional[str]:
@@ -157,9 +198,9 @@ def _extract_real_path_from_sessions(project_dir: Path) -> Optional[str]:
157198
try:
158199
data = json.loads(line.strip())
159200
cwd = data.get("cwd")
160-
# Validate cwd is an absolute path
161-
if cwd and Path(cwd).is_absolute():
162-
return cwd
201+
if cwd and _is_absolute_path(cwd):
202+
# Normalize Windows backslashes to forward slashes
203+
return cwd.replace("\\", "/")
163204
except json.JSONDecodeError:
164205
continue
165206
except (OSError, PermissionError, UnicodeDecodeError):

api/routers/agent_analytics.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
AgentUsageListResponse,
2323
AgentUsageSummary,
2424
)
25+
from utils import is_encoded_project_dir
2526

2627
logger = logging.getLogger(__name__)
2728

@@ -83,14 +84,12 @@ def _get_all_project_agent_names() -> frozenset[str]:
8384
for encoded_dir in projects_dir.iterdir():
8485
if not encoded_dir.is_dir() or encoded_dir.name.startswith("."):
8586
continue
86-
# Decode: leading "-" → "/", all "-" → "/"
87-
decoded_path = (
88-
"/" + encoded_dir.name[1:].replace("-", "/")
89-
if encoded_dir.name.startswith("-")
90-
else None
91-
)
92-
if not decoded_path:
87+
if not is_encoded_project_dir(encoded_dir.name):
9388
continue
89+
# Decode to real project path using Project model
90+
from models.project import Project
91+
92+
decoded_path = Project.decode_path(encoded_dir.name)
9493
project_dir = Path(decoded_path)
9594
if not project_dir.is_dir():
9695
continue

api/routers/history.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,10 @@ async def get_project_archived_prompts(
244244

245245
if not archived_projects:
246246
# Return empty response rather than 404 - project may just have no archived prompts
247-
# Decode the path to get project name
248-
project_path = (
249-
"/" + encoded_name[1:].replace("-", "/")
250-
if encoded_name.startswith("-")
251-
else encoded_name.replace("-", "/")
252-
)
247+
# Decode the path using Project model (handles both Unix and Windows)
248+
from models.project import Project
249+
250+
project_path = Project.decode_path(encoded_name)
253251
return ProjectArchivedResponse(
254252
project_name=get_project_name(project_path),
255253
project_path=project_path,

api/routers/plugins.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
load_installed_plugins,
2727
)
2828
from models.plugin import (
29-
_resolve_manifest_dirs,
3029
get_plugin_description,
3130
read_command_contents,
3231
read_plugin_manifest,
32+
resolve_manifest_dirs,
3333
scan_plugin_capabilities,
3434
)
3535
from schemas import (
@@ -1232,7 +1232,7 @@ def list_plugin_skills(plugin_name: str, request: Request) -> list[SkillItem]:
12321232
seen_names: set[str] = set()
12331233

12341234
# Scan skills directories for SKILL.md files
1235-
for skills_dir in _resolve_manifest_dirs(install_path, manifest, "skills", ["skills"]):
1235+
for skills_dir in resolve_manifest_dirs(install_path, manifest, "skills", ["skills"]):
12361236
try:
12371237
for skill_md in sorted(
12381238
skills_dir.rglob("SKILL.md"), key=lambda p: p.parent.name.lower()
@@ -1258,7 +1258,7 @@ def list_plugin_skills(plugin_name: str, request: Request) -> list[SkillItem]:
12581258
logger.error(f"Failed to scan skills directory {skills_dir}: {e}")
12591259

12601260
# Scan commands directories for .md files
1261-
for commands_dir in _resolve_manifest_dirs(install_path, manifest, "commands", ["commands"]):
1261+
for commands_dir in resolve_manifest_dirs(install_path, manifest, "commands", ["commands"]):
12621262
try:
12631263
for entry in sorted(commands_dir.iterdir(), key=lambda p: p.name.lower()):
12641264
if entry.name.startswith(".") or not entry.is_file():
@@ -1350,7 +1350,7 @@ def get_plugin_skill_content(
13501350
target_file = None
13511351

13521352
# Search skills directories for SKILL.md (path is skill name)
1353-
for skills_dir in _resolve_manifest_dirs(install_path, manifest, "skills", ["skills"]):
1353+
for skills_dir in resolve_manifest_dirs(install_path, manifest, "skills", ["skills"]):
13541354
candidate = (skills_dir / clean_path / "SKILL.md").resolve()
13551355
try:
13561356
candidate.relative_to(skills_dir.resolve())
@@ -1362,9 +1362,7 @@ def get_plugin_skill_content(
13621362

13631363
# Search commands directories for .md file
13641364
if target_file is None:
1365-
for commands_dir in _resolve_manifest_dirs(
1366-
install_path, manifest, "commands", ["commands"]
1367-
):
1365+
for commands_dir in resolve_manifest_dirs(install_path, manifest, "commands", ["commands"]):
13681366
candidate = (commands_dir / clean_path).resolve()
13691367
try:
13701368
candidate.relative_to(commands_dir.resolve())

0 commit comments

Comments
 (0)