Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/clawpm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,28 @@ def tasks_add(
)

if not task:
output_error("add_failed", f"Failed to add task to project '{project_id}'", fmt=fmt)
# Give a more useful hint: check if the project exists locally but has
# a malformed settings.toml (e.g. Windows backslashes in repo_path).
from pathlib import Path as _Path
_current = _Path.cwd().resolve()
_settings_exists = False
while _current != _current.parent:
if (_current / ".project" / "settings.toml").exists():
_settings_exists = True
break
_current = _current.parent

if _settings_exists:
output_error(
"add_failed",
f"Failed to add task to project '{project_id}'. "
f"A .project/settings.toml exists locally but could not be loaded from the "
f"portfolio registry - the file may contain Windows backslashes in repo_path. "
f"Fix it by using forward slashes (e.g. F:/Git/...) then retry.",
fmt=fmt,
)
else:
output_error("add_failed", f"Failed to add task to project '{project_id}'", fmt=fmt)
sys.exit(1)

output_success(f"Task {task.id} created", data=task.to_dict(), fmt=fmt)
Expand Down
161 changes: 122 additions & 39 deletions src/clawpm/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@

from __future__ import annotations

import logging
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path

from .models import PortfolioConfig, ProjectSettings, ProjectStatus

logger = logging.getLogger(__name__)


@dataclass
class UntrackedRepo:
"""A git repo without .project/ tracking."""

path: Path
name: str
remote: str | None = None

def to_dict(self) -> dict:
return {
"path": str(self.path),
Expand All @@ -28,17 +32,22 @@ def to_dict(self) -> dict:


def path_for_config(p: Path) -> str:
"""Convert a path to a config-friendly string, using ~/ when possible."""
"""Convert a path to a config-friendly string, using ~/ when possible.

Always uses forward slashes so the result is valid in TOML string literals
on all platforms (backslashes are TOML escape sequences and break parsing).
"""
try:
relative = p.relative_to(Path.home())
return f"~/{relative}"
# Use posix separators so ~/ paths are cross-platform and TOML-safe
return f"~/{relative.as_posix()}"
except ValueError:
return str(p)
return p.as_posix()


def get_portfolio_path() -> Path | None:
"""Get the portfolio path from default location or environment override.

Returns the portfolio root directory, or None if not found.
Note: Even if None is returned, load_portfolio_config() will use defaults.
"""
Expand All @@ -58,10 +67,10 @@ def get_portfolio_path() -> Path | None:

def load_portfolio_config(portfolio_path: Path | None = None) -> PortfolioConfig | None:
"""Load portfolio configuration.

If portfolio.toml exists, loads it. Otherwise creates a default config
with sensible defaults (~/clawpm/projects as project root).

Environment variables:
CLAWPM_PORTFOLIO: Override portfolio root directory
CLAWPM_PROJECT_ROOTS: Colon-separated list of additional project roots
Expand All @@ -86,18 +95,18 @@ def load_portfolio_config(portfolio_path: Path | None = None) -> PortfolioConfig
def _default_portfolio_config() -> PortfolioConfig:
"""Create a default portfolio config with sensible defaults."""
from .models import PortfolioConfig, ProjectStatus

portfolio_root = Path.home() / "clawpm"

# Default project roots: ~/clawpm/projects
project_roots = [portfolio_root / "projects"]

# Add any from environment
if env_roots := os.environ.get("CLAWPM_PROJECT_ROOTS"):
for root in env_roots.split(":"):
if root.strip():
project_roots.append(Path(root.strip()).expanduser())

# OpenClaw workspace from env or default
openclaw_workspace = None
if ws := os.environ.get("CLAWPM_WORKSPACE"):
Expand All @@ -106,7 +115,7 @@ def _default_portfolio_config() -> PortfolioConfig:
default_ws = Path.home() / ".openclaw" / "workspace"
if default_ws.exists():
openclaw_workspace = default_ws

return PortfolioConfig(
portfolio_root=portfolio_root,
project_roots=project_roots,
Expand Down Expand Up @@ -177,8 +186,13 @@ def get_project(config: PortfolioConfig, project_id: str) -> ProjectSettings | N
if direct.exists():
try:
return ProjectSettings.load(direct)
except Exception:
pass
except Exception as exc:
logger.warning(
"Failed to load settings.toml for '%s' at %s: %s. "
"The file may contain backslashes in repo_path - use forward slashes.",
project_id, direct, exc,
)
# Fall through to full scan in case of id mismatch; don't return yet

# Search all projects
for item in root.iterdir():
Expand All @@ -191,24 +205,93 @@ def get_project(config: PortfolioConfig, project_id: str) -> ProjectSettings | N
project = ProjectSettings.load(settings_file)
if project.id == project_id:
return project
except Exception:
except Exception as exc:
logger.warning(
"Failed to load settings.toml at %s: %s. "
"The file may contain backslashes in repo_path - use forward slashes.",
settings_file, exc,
)
continue

return None


def get_project_dir(config: PortfolioConfig, project_id: str) -> Path | None:
"""Get the .project directory for a project."""
"""Get the .project directory for a project.

Returns the ``.project/`` directory path (not the repo root) when found,
or ``None`` if the project cannot be located via the portfolio registry.

Use :func:`find_project_dir_fallback` if you need a best-effort lookup
that also checks the CWD walk when the registry lookup fails.
"""
project = get_project(config, project_id)
if project and project.project_dir:
return project.project_dir / ".project"
return None


def _read_project_id_from_settings(settings_file: Path) -> str | None:
"""Extract the ``id`` field from a settings.toml using TOML parse first,
then a regex fallback for malformed files (e.g. Windows backslashes).

Returns the id string or None if it cannot be determined.
"""
try:
project = ProjectSettings.load(settings_file)
return project.id
except Exception:
pass

# TOML parse failed - try regex extraction of the id field only.
# This handles the common case where only repo_path has backslashes.
try:
text = settings_file.read_text(encoding="utf-8", errors="replace")
match = re.search(r'^id\s*=\s*"([^"]+)"', text, re.MULTILINE)
if match:
return match.group(1)
except Exception:
pass

return None


def find_project_dir_fallback(config: PortfolioConfig, project_id: str) -> Path | None:
"""Locate the ``.project/`` directory for *project_id* when the registry fails.

The portfolio registry (``get_project``) can fail to load a project when its
``settings.toml`` is malformed (e.g. Windows backslashes in ``repo_path``).
This function tries an additional approach: walk up from the current working
directory looking for a ``.project/settings.toml`` whose ``id`` field matches.

The id is extracted with a regex fallback so malformed TOML files (where only
``repo_path`` is broken) do not block the lookup.

Returns the ``.project/`` directory if found, ``None`` otherwise.
Logs an info-level note about the divergence so it shows up in debug output
without spamming normal operation.
"""
cwd = Path.cwd().resolve()
current = cwd
while current != current.parent:
settings_file = current / ".project" / "settings.toml"
if settings_file.exists():
found_id = _read_project_id_from_settings(settings_file)
if found_id == project_id:
logger.info(
"Project '%s' found via CWD walk at %s but not via portfolio registry. "
"Check settings.toml for backslashes in repo_path (use forward slashes).",
project_id, settings_file,
)
return current / ".project"
current = current.parent
return None


def discover_untracked_repos(config: PortfolioConfig) -> list[UntrackedRepo]:
"""Discover git repos in project_roots that don't have .project/ tracking."""
untracked: list[UntrackedRepo] = []

# Get IDs of tracked projects to exclude
tracked_paths = set()
for root in config.project_roots:
Expand All @@ -219,28 +302,28 @@ def discover_untracked_repos(config: PortfolioConfig) -> list[UntrackedRepo]:
continue
if (item / ".project" / "settings.toml").exists():
tracked_paths.add(item.resolve())

# Find git repos without .project/
for root in config.project_roots:
if not root.exists():
continue

# Skip OpenClaw workspace
if config.openclaw_workspace and root == config.openclaw_workspace:
continue

for item in root.iterdir():
if not item.is_dir():
continue

# Skip if already tracked
if item.resolve() in tracked_paths:
continue

# Check if it's a git repo
if not (item / ".git").exists():
continue

# Get remote URL if available
remote = None
try:
Expand All @@ -255,16 +338,16 @@ def discover_untracked_repos(config: PortfolioConfig) -> list[UntrackedRepo]:
remote = result.stdout.strip()
except Exception:
pass

untracked.append(UntrackedRepo(
path=item,
name=item.name,
remote=remote,
))

# Sort by name
untracked.sort(key=lambda r: r.name)

return untracked


Expand All @@ -275,20 +358,20 @@ def is_git_repo(path: Path) -> bool:

def init_project_from_repo(repo_path: Path, project_id: str | None = None) -> ProjectSettings | None:
"""Initialize a .project/ structure in a git repo.

Auto-detects project name from directory and remote.
Returns the created ProjectSettings.
"""
if not repo_path.is_dir():
return None

# Generate project ID from directory name if not provided
if not project_id:
project_id = repo_path.name.lower().replace(" ", "-").replace("_", "-")

# Generate project name from directory or remote
project_name = repo_path.name

# Try to get a better name from git remote
try:
result = subprocess.run(
Expand All @@ -310,20 +393,20 @@ def init_project_from_repo(repo_path: Path, project_id: str | None = None) -> Pr
project_name = name
except Exception:
pass

# Create .project directory structure
project_dir = repo_path / ".project"
project_dir.mkdir(exist_ok=True)

tasks_dir = project_dir / "tasks"
tasks_dir.mkdir(exist_ok=True)
(tasks_dir / "done").mkdir(exist_ok=True)
(tasks_dir / "blocked").mkdir(exist_ok=True)

(project_dir / "research").mkdir(exist_ok=True)
(project_dir / "notes").mkdir(exist_ok=True)
# Write settings.toml

# Write settings.toml - path_for_config produces forward slashes (TOML-safe)
repo_path_str = path_for_config(repo_path)
settings_content = f'''id = "{project_id}"
name = "{project_name}"
Expand All @@ -332,7 +415,7 @@ def init_project_from_repo(repo_path: Path, project_id: str | None = None) -> Pr
repo_path = "{repo_path_str}"
'''
(project_dir / "settings.toml").write_text(settings_content)

# Create minimal SPEC.md
spec_content = f'''# {project_name}

Expand All @@ -349,10 +432,10 @@ def init_project_from_repo(repo_path: Path, project_id: str | None = None) -> Pr
Auto-initialized by clawpm from git repo.
'''
(project_dir / "SPEC.md").write_text(spec_content)

# Create learnings.md
(project_dir / "learnings.md").write_text(f"# Learnings - {project_name}\n\n")

# Load and return the project
return ProjectSettings.load(project_dir / "settings.toml")

Expand Down
Loading