feat(desktop): add Electron desktop pet for Claude CLI
- Electron + React + TypeScript 桌面宠物外壳 - xterm.js + node-pty 直接对接 Claude CLI 进程 - 宠物 orb(80x80)可拖拽,点击展开终端面板 - CRT 复古终端主题(扫描线、文字发光) - 全局快捷键 Cmd+Shift+C 显隐 - 窗口位置自动保存 - 中华田园犬吉祥物 icon(AI 生成) - Makefile 新增 `make app` 打包命令
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""Sync workspace symlinks from zshrc environment variables."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from bin.project_root import get_myclaude_project_root, get_workspace_root
|
||||
|
||||
console = Console()
|
||||
stderr_console = Console(stderr=True)
|
||||
|
||||
# Link name -> list of environment variable names to try, in order.
|
||||
_LINK_MAP: dict[str, list[str]] = {
|
||||
"myacademia": ["path_myacademia"],
|
||||
"myslides": ["path_myslides", "MYSLIDES_ROOT"],
|
||||
"metabot": ["METABOT_HOME"],
|
||||
"mytoolkit": ["path_mytoolkit", "MYTOOLKIT_ROOT"],
|
||||
"mywebpage": ["path_mywebpage", "MYWEBPAGE_ROOT"],
|
||||
}
|
||||
|
||||
|
||||
def _read_zsh_env(var: str, zshrc: Path) -> str | None:
|
||||
"""Source zshrc and print the value of a variable."""
|
||||
cmd = [
|
||||
"zsh",
|
||||
"-c",
|
||||
f"source '{zshrc}' >/dev/null 2>&1; echo -n ${var}",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
value = result.stdout.strip()
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _resolve_target(
|
||||
env_vars: list[str],
|
||||
zshrc: Path,
|
||||
) -> Path | None:
|
||||
"""Find the first valid target path from environment variables."""
|
||||
for var in env_vars:
|
||||
raw = _read_zsh_env(var, zshrc)
|
||||
if raw is None:
|
||||
continue
|
||||
expanded = os.path.expandvars(os.path.expanduser(raw))
|
||||
path = Path(expanded)
|
||||
if path.exists():
|
||||
return path.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _current_link_target(workspace: Path, name: str) -> Path | None:
|
||||
"""Return the target of an existing symlink, or None."""
|
||||
link = workspace / name
|
||||
if link.is_symlink():
|
||||
try:
|
||||
return Path(os.readlink(link))
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sync_link(
|
||||
workspace: Path,
|
||||
name: str,
|
||||
target: Path,
|
||||
dry_run: bool,
|
||||
force: bool,
|
||||
) -> bool:
|
||||
"""Create or update a symlink. Returns True if changed."""
|
||||
link = workspace / name
|
||||
current = _current_link_target(workspace, name)
|
||||
|
||||
if current is not None and current.resolve() == target.resolve():
|
||||
return False
|
||||
|
||||
if link.exists() and not link.is_symlink():
|
||||
if not force:
|
||||
stderr_console.print(
|
||||
f"[yellow]{name} exists but is not a symlink. "
|
||||
"Use --force to replace.[/yellow]"
|
||||
)
|
||||
return False
|
||||
if dry_run:
|
||||
console.print(
|
||||
f"[dry-run] Would remove {name} and symlink -> {target}"
|
||||
)
|
||||
return True
|
||||
if link.is_dir():
|
||||
link.rmdir()
|
||||
else:
|
||||
link.unlink()
|
||||
elif current is not None:
|
||||
if dry_run:
|
||||
console.print(
|
||||
f"[dry-run] Would update {name}: {current} -> {target}"
|
||||
)
|
||||
return True
|
||||
link.unlink()
|
||||
else:
|
||||
if dry_run:
|
||||
console.print(f"[dry-run] Would create {name} -> {target}")
|
||||
return True
|
||||
|
||||
link.symlink_to(target, target_is_directory=target.is_dir())
|
||||
return True
|
||||
|
||||
|
||||
def sync_workspace_links(
|
||||
zshrc: Path | None = None,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
verbose: bool = True,
|
||||
) -> bool:
|
||||
"""Sync all workspace symlinks. Returns True on success."""
|
||||
workspace = get_workspace_root()
|
||||
if zshrc is None:
|
||||
zshrc = Path.home() / ".zshrc"
|
||||
|
||||
if verbose:
|
||||
console.print(f"[bold]Workspace:[/bold] {workspace}")
|
||||
console.print(f"[bold]Zshrc:[/bold] {zshrc}")
|
||||
console.print()
|
||||
|
||||
changed = 0
|
||||
skipped = 0
|
||||
broken = 0
|
||||
|
||||
# Handle myclaude separately: use project root detection, not zshrc.
|
||||
myclaude_target = get_myclaude_project_root()
|
||||
if _sync_link(workspace, "myclaude", myclaude_target, dry_run, force):
|
||||
action = "would update" if dry_run else "updated"
|
||||
if verbose:
|
||||
console.print(
|
||||
f"[green]myclaude: {action} -> {myclaude_target}[/green]"
|
||||
)
|
||||
changed += 1
|
||||
else:
|
||||
if verbose:
|
||||
console.print("[dim]myclaude: already correct[/dim]")
|
||||
|
||||
for name, env_vars in _LINK_MAP.items():
|
||||
target = _resolve_target(env_vars, zshrc)
|
||||
if target is None:
|
||||
link = workspace / name
|
||||
if link.is_symlink() and not link.exists():
|
||||
if verbose:
|
||||
stderr_console.print(
|
||||
f"[red]{name}: broken symlink (no env var found)[/red]"
|
||||
)
|
||||
broken += 1
|
||||
else:
|
||||
if verbose:
|
||||
console.print(f"[dim]{name}: skipped (no env var)[/dim]")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if _sync_link(workspace, name, target, dry_run, force):
|
||||
action = "would update" if dry_run else "updated"
|
||||
if verbose:
|
||||
console.print(f"[green]{name}: {action} -> {target}[/green]")
|
||||
changed += 1
|
||||
else:
|
||||
if verbose:
|
||||
console.print(f"[dim]{name}: already correct[/dim]")
|
||||
|
||||
if verbose:
|
||||
console.print()
|
||||
label = "Preview" if dry_run else "Done"
|
||||
console.print(
|
||||
f"[bold]{label}:[/bold] {changed} changed, "
|
||||
f"{skipped} skipped, {broken} broken."
|
||||
)
|
||||
|
||||
return broken == 0
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from bin.commands.init import sync_workspace_links
|
||||
from bin.project_root import get_myclaude_project_root
|
||||
|
||||
console = Console()
|
||||
@@ -28,7 +29,7 @@ def _run_pip_editable(root: Path) -> int:
|
||||
|
||||
@click.command("update")
|
||||
def update_cmd() -> None:
|
||||
"""Reinstall myclaude from this repository (make install, or pip install -e)."""
|
||||
"""Reinstall myclaude and sync workspace symlinks."""
|
||||
root = get_myclaude_project_root()
|
||||
fallback = Path.home() / ".myclaude"
|
||||
if root == fallback:
|
||||
@@ -54,3 +55,7 @@ def update_cmd() -> None:
|
||||
stderr_console.print("[red]Update failed.[/red]")
|
||||
raise SystemExit(rc)
|
||||
console.print("[green]myclaude updated.[/green]")
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]Syncing workspace links…[/bold cyan]")
|
||||
sync_workspace_links()
|
||||
|
||||
Reference in New Issue
Block a user