From 7f1aa9f27e35242d1a1ef41b17e10a277796ad69 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Wed, 1 Jul 2026 23:27:14 +0800 Subject: [PATCH] rename: myclaude -> myagents --- CLAUDE.md | 16 +- Makefile | 33 +- README.md | 566 +++----------------- myagents/__init__.py | 1 + myagents/__main__.py | 3 + myagents/cli.py | 42 ++ {myclaude => myagents}/commands/__init__.py | 2 +- {myclaude => myagents}/commands/init.py | 12 +- {myclaude => myagents}/commands/update.py | 18 +- myagents/entrypoints.py | 16 + myagents/launcher.py | 276 ++++++++++ {myclaude => myagents}/project_root.py | 28 +- myclaude/__init__.py | 1 - myclaude/__main__.py | 3 - myclaude/cli.py | 198 ------- pyproject.toml | 10 +- scripts/install_completion.sh | 48 +- scripts/rm_user_local_myagents.py | 52 ++ scripts/rm_user_local_myclaude.py | 37 -- tests/test_cli.py | 309 +++++++---- tests/test_entrypoints.py | 75 +++ tests/test_project_root.py | 48 +- uv.lock | 2 +- 23 files changed, 859 insertions(+), 937 deletions(-) create mode 100644 myagents/__init__.py create mode 100644 myagents/__main__.py create mode 100644 myagents/cli.py rename {myclaude => myagents}/commands/__init__.py (78%) rename {myclaude => myagents}/commands/init.py (93%) rename {myclaude => myagents}/commands/update.py (70%) create mode 100644 myagents/entrypoints.py create mode 100644 myagents/launcher.py rename {myclaude => myagents}/project_root.py (65%) delete mode 100644 myclaude/__init__.py delete mode 100644 myclaude/__main__.py delete mode 100644 myclaude/cli.py create mode 100644 scripts/rm_user_local_myagents.py delete mode 100644 scripts/rm_user_local_myclaude.py create mode 100644 tests/test_entrypoints.py diff --git a/CLAUDE.md b/CLAUDE.md index 3d31891..c8cce33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# Claude 个人助理 — myclaude CLI 开发配置 +# Claude 个人助理 — myagents CLI 开发配置 -你是 Claude,协助开发 myclaude CLI 工具。日常工作场景请见 workspace/CLAUDE.md。 +你是 Claude,协助开发 myagents CLI 工具。日常工作场景请见 workspace/CLAUDE.md。 --- @@ -41,9 +41,11 @@ ## 3. 项目结构 ``` -myclaude/ -├── bin/ # Python 源码 +myagents/ +├── myagents/ # Python 源码 │ ├── cli.py # CLI entrypoint +│ ├── launcher.py # Agent 启动器 +│ ├── entrypoints.py # myclaude / mykimi 独立入口 │ ├── commands/ # 子命令 │ └── ... ├── templates/ # 模板目录 @@ -57,9 +59,9 @@ workspace/ # 主工作环境 (默认 --cwd 目标, 独立目录) └── tmp/ # 临时文件 ``` -默认运行 `myclaude`(不带 --cwd)时,cwd 为 `MYCLAUDE_WORKSPACE_ROOT` -(默认 `~/workspace`),指向独立工作区 CLAUDE.md。 -开发 CLI 时运行 `myclaude --cwd .` 以读取本文件。 +默认运行 `myagents claude` 或 `myagents kimi`(不带 --cwd)时,cwd 为 +`MYAGENTS_WORKSPACE_ROOT`(默认 `~/workspace`),指向独立工作区 CLAUDE.md。 +开发 CLI 时运行 `myagents claude --cwd .` 或 `myagents kimi --cwd .` 以读取本文件。 --- diff --git a/Makefile b/Makefile index bf5196b..79636fc 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ ROOT_DIR := $(shell pwd) -VENV_MYCLAUDE := $(ROOT_DIR)/.venv/bin/myclaude -USER_LOCAL_MYCLAUDE := $(HOME)/.local/bin/myclaude +VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin +USER_BIN_DIR := $(HOME)/.local/bin +COMMANDS := myagents myclaude mykimi COMP_DIR := $(HOME)/.local/bin/completions -.PHONY: help install uninstall _symlink-myclaude _install-completions _uninstall-completions +.PHONY: help install uninstall _symlink-commands _install-completions _uninstall-completions help: @echo "Usage: make [target]" @@ -18,22 +19,28 @@ install: test -x .venv/bin/pip || python3 -m venv .venv; \ PIP_USER=0 .venv/bin/pip install -e .; \ fi - @$(MAKE) _symlink-myclaude + @$(MAKE) _symlink-commands @$(MAKE) _install-completions -_symlink-myclaude: - @test -x "$(VENV_MYCLAUDE)" || { echo "error: missing $(VENV_MYCLAUDE)"; exit 1; } - @mkdir -p "$(HOME)/.local/bin" - @ln -sf "$(VENV_MYCLAUDE)" "$(USER_LOCAL_MYCLAUDE)" - @echo "Linked $(USER_LOCAL_MYCLAUDE) -> $(VENV_MYCLAUDE)" +_symlink-commands: + @mkdir -p "$(USER_BIN_DIR)" + @for cmd in $(COMMANDS); do \ + src="$(VENV_BIN_DIR)/$$cmd"; \ + dst="$(USER_BIN_DIR)/$$cmd"; \ + test -x "$$src" || { echo "error: missing $$src"; exit 1; }; \ + ln -sf "$$src" "$$dst"; \ + echo "Linked $$dst -> $$src"; \ + done _install-completions: - @$(ROOT_DIR)/scripts/install_completion.sh "$(VENV_MYCLAUDE)" "$(COMP_DIR)" + @$(ROOT_DIR)/scripts/install_completion.sh "$(VENV_BIN_DIR)" "$(COMP_DIR)" uninstall: _uninstall-completions - @ROOT_DIR="$(ROOT_DIR)" python3 "$(ROOT_DIR)/scripts/rm_user_local_myclaude.py" + @ROOT_DIR="$(ROOT_DIR)" python3 "$(ROOT_DIR)/scripts/rm_user_local_myagents.py" _uninstall-completions: - @rm -f "$(COMP_DIR)/_myclaude" "$(COMP_DIR)/myclaude.bash" + @rm -f \ + "$(COMP_DIR)/_myagents" "$(COMP_DIR)/myagents.bash" \ + "$(COMP_DIR)/_myclaude" "$(COMP_DIR)/myclaude.bash" \ + "$(COMP_DIR)/_mykimi" "$(COMP_DIR)/mykimi.bash" @echo "Removed completions" - diff --git a/README.md b/README.md index 92ef5ff..5921a1e 100644 --- a/README.md +++ b/README.md @@ -1,535 +1,121 @@ -# MyClaude +# MyAgents -一个基于 Claude Code 的个人助理工具,支持通过飞书机器人进行远程交互。 +一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI 等工具。 -## 功能特性 +## 功能 -- 🤖 多模型支持:Claude、DeepSeek、Kimi、Kimi Code -- 💬 飞书机器人集成:随时随地与 AI 助手对话 -- 📝 智能记忆:自动保存对话历史和项目上下文 -- 🔧 技能扩展:支持自定义 Skill 增强功能 - -## 视频教程 - -📺 **部署流程详解**:[Bilibili - MyClaude 完整部署指南](https://www.bilibili.com/video/BV15TPrzeELm/) +- 🤖 **多 Agent 统一入口**:`myagents claude` / `myagents kimi` +- 🚀 **独立快捷命令**:`myclaude`、`mykimi` +- 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖 +- 📝 **Session 管理**:`--list` 列出可恢复会话 +- ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传 ## 项目结构 ``` -myclaude/ -├── .claude/ # Claude Code 配置 -│ └── skills/ # 自定义技能 -├── .venv/ # Python 虚拟环境 -├── templates/ # 模板目录 -│ └── workspace/ # 工作区模板(CLAUDE.md, .gitignore) -├── bin/ # Python 源码 -│ ├── cli.py # CLI 入口 -│ ├── commands/ # 子命令 -│ └── ... -├── CLAUDE.md # 项目配置(自动生成) -├── pyproject.toml # Python 项目配置 -└── README.md # 本文件 +myagents/ +├── myagents/ +│ ├── cli.py # myagents 统一入口 +│ ├── entrypoints.py # myclaude / mykimi 独立入口 +│ ├── launcher.py # Agent 启动逻辑 +│ ├── project_root.py # 项目/工作区根目录解析 +│ └── commands/ # update / upgrade 子命令 +├── tests/ +├── templates/ # workspace 模板 +├── pyproject.toml +├── Makefile +└── README.md ``` -## 快速开始 +## 安装 -### 1. 安装 Claude Code - -**macOS/Linux:** - -```bash -# 推荐:官方安装脚本 -curl -fsSL https://claude.ai/install.sh | sh - -# 备选:npm(Homebrew 不可用时,固定 2.1.118,新版本有 Kimi 兼容问题) -npm install -g @anthropic-ai/claude-code@2.1.118 -``` - -> ⚠️ **npm 安装已弃用**:`npm install -g @anthropic-ai/claude-code` 从 2026-01-21(v2.1.15)起已被 Anthropic 官方弃用,未来版本可能失效。请优先使用官方安装脚本。 - -**Windows:** - -```powershell -# 推荐:官方安装脚本 -irm https://claude.ai/install.ps1 | iex - -# 或 winget -winget install Anthropic.ClaudeCode -``` - -> **注意**:Windows 用户需先安装 [Git for Windows](https://git-scm.com/download/win) -> -> **💡 认证提示**:首次运行 Claude Code 时,建议选择 **方案 2 (API Key)**,但将 API Token 留空。这样 Claude 会使用 cc-switch 的配置,方便后续通过 cc-switch 切换不同模型。 -> -> **来源**:[Claude Code 官方文档](https://code.claude.com/docs/en/quickstart) - -安装完成后验证: - -```bash -claude --version -``` - -### 2. 开通大模型会员(推荐) - -| 服务 | 说明 | 开通地址 | -|------|------|---------| -| DeepSeek | 国产高性能大模型 | https://platform.deepseek.com | -| Kimi | 长文本处理专家,API 按量计费 | https://kimi.moonshot.cn | -| Kimi Code | 代码专用模型,包月/包年订阅更实惠 | Kimi 内开通 Code 会员 | - -### 3. 安装 cc-switch(多模型切换) - -**方式 1:下载安装包(推荐,全平台通用)** - -访问 https://github.com/farion1231/cc-switch/releases 下载对应安装包: - -- **macOS**: `.dmg` 或 `.pkg` -- **Linux**: `.deb` (Debian/Ubuntu) 或 `.rpm` (RHEL/CentOS) -- **Windows**: `.msi` - -**方式 2:包管理器安装** - -macOS (Homebrew): - -```bash -brew update -brew tap farion1231/tap -brew install cc-switch -``` - -Linux (Debian/Ubuntu): - -```bash -# 下载 .deb 包后 -sudo dpkg -i cc-switch_*.deb -# 如有依赖问题,运行: -sudo apt-get install -f -``` - -Linux (RHEL/CentOS/Fedora): - -```bash -sudo rpm -i cc-switch_*.rpm -``` - -**配置模型:** - -> 💡 **推荐 GUI 配置**: -> 1. 启动 cc-switch 桌面应用 -> 2. 点击「Add」添加模型 -> 3. 选择模型类型(DeepSeek/Kimi/Claude 等),粘贴 API Key -> 4. 点击「Set Default」设置默认模型 -> -> **命令行配置**(可选): -> ```bash -> cc-switch add deepseek --api-key YOUR_KEY -> cc-switch add kimi --api-key YOUR_KEY -> cc-switch default claude -> ``` - -### 4. 安装 myclaude - -**前置依赖:** +**前置依赖** | 工具 | 版本要求 | 说明 | -|------|---------|------| +|---|---|---| | Python | 3.10+ | 项目运行环境 | | make | - | 构建工具(macOS/Linux 通常自带) | | git | - | 代码仓库管理 | | uv 或 pip | - | Python 包管理器,推荐 [uv](https://github.com/astral-sh/uv) | -**检查依赖:** +**克隆并安装** ```bash -python3 --version # 需 >= 3.10 -make --version # 确认已安装 -git --version # 确认已安装 -``` - -**克隆并安装:** - -```bash -# 克隆到本地 -git clone ~/your-path/myclaude -cd ~/your-path/myclaude - -# 安装(uv 则 uv sync,否则创建 .venv 并用 pip install -e) +git clone /path/to/myagents +cd /path/to/myagents make install ``` -会在 `~/.local/bin/myclaude` 创建指向本仓库 `.venv/bin/myclaude` 的符号链接;请保证 `~/.local/bin` 在 `PATH` 中(多数发行版默认已包含)。移除该链接:`make uninstall`(仅当链接目标为本仓库的 `.venv/bin/myclaude` 时才会删除)。 +`make install` 会: +1. 同步/创建虚拟环境并做可编辑安装 +2. 在 `~/.local/bin/` 创建 `myagents`、`myclaude`、`mykimi` 符号链接 +3. 安装 shell 补全(zsh/bash) -> 💡 **提示**:无 uv 时会创建项目 `.venv` 并用其中的 pip 做可编辑安装。 +请确保 `~/.local/bin` 在 `PATH` 中。移除链接:`make uninstall`。 -**配置工作区(可选):** +> 💡 **提示**:没有 uv 时会创建项目 `.venv` 并用其中的 pip 做可编辑安装。 -默认工作区为 `~/workspace`。如需自定义,设置环境变量: +## 命令用法 ```bash -export MYCLAUDE_WORKSPACE_ROOT="/your/custom/workspace" +# 统一入口 +myagents # 显示 help +myagents claude # 启动 Claude Code +myagents claude --cwd . # 指定工作目录 +myagents claude -l # 列出可恢复 sessions +myagents claude -r # 恢复指定 session +myagents kimi # 启动 Kimi Code CLI +myagents kimi -S # 恢复 Kimi session +myagents update # 重装 myagents 并同步 workspace 链接 +myagents upgrade # update 别名 + +# 独立入口(与上面完全等价) +myclaude +myclaude --cwd . -r +mykimi +mykimi -S ``` -首次运行 `myclaude` 时,会自动创建工作区目录并复制 `templates/workspace/` 中的模板文件。 +各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session`、`-c`(continue)、`-y`(yolo)等,直接加在命令后即可。 -**Shell 补全(可选):** 在 `~/.zshrc` 或 `~/.bashrc` 中加入(有命令再注册,避免未安装时报错): +## 配置 + +| 环境变量 | 说明 | +|---|---| +| `MYAGENTS_PROJECT_ROOT` | 覆盖项目根目录自动检测 | +| `MYAGENTS_WORKSPACE_ROOT` | 覆盖 workspace 根目录,默认 `~/workspace` | + +设置示例: ```bash -if command -v myclaude >/dev/null 2>&1; then - eval "$(_MYCLAUDE_COMPLETE=zsh_source myclaude)" +export MYAGENTS_WORKSPACE_ROOT="/your/custom/workspace" +``` + +## Shell 补全(可选) + +在 `~/.zshrc` 或 `~/.bashrc` 中加入: + +```bash +if command -v myagents >/dev/null 2>&1; then + eval "$(_MYAGENTS_COMPLETE=zsh_source myagents)" fi ``` -Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 该配置文件。 +Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 配置文件。 -### 5. 安装 Metabot - -**快速安装:** +## 开发 ```bash -# macOS/Linux -curl -fsSL https://raw.githubusercontent.com/xvirobotics/metabot/main/install.sh | bash +# 运行测试 +python3 -m pytest tests/ -q -# Windows (PowerShell) -irm https://raw.githubusercontent.com/xvirobotics/metabot/main/install.ps1 | iex +# 代码检查 +python3 -m ruff check myagents tests scripts ``` -安装器会引导完成:工作目录设置 → Claude 认证 → IM 平台选择 → 机器人配置 → PM2 自启动 +## 完整部署指南 -**配置环境变量:** - -Metabot 命令安装后需要添加到 PATH: - -```bash -# 1. 确定 shell 类型 -echo $SHELL -# /bin/zsh → 编辑 ~/.zshrc -# /bin/bash → 编辑 ~/.bashrc (Linux) 或 ~/.bash_profile (macOS) - -# 2. 添加 PATH -export PATH="$PATH:$HOME/.local/bin" - -# 3. 使配置生效(二选一) -source ~/.zshrc # 或 source ~/.bashrc -# 或:重开一个终端窗口 -``` - -**启用命令自动补全(可选):** - -```bash -# zsh: 编辑 ~/.zshrc -fpath=($HOME/.local/bin/completions $fpath) -autoload -Uz compinit && compinit - -# bash: 编辑 ~/.bashrc -for f in $HOME/.local/bin/completions/*; do - [ -f "$f" ] && source "$f" -done -``` - -**基本命令:** - -```bash -metabot start # 启动飞书机器人 -metabot stop # 停止机器人 -metabot restart # 重启机器人 -metabot status # 查看运行状态 -metabot logs -f # 查看实时日志 -metabot update # 更新到最新版本 -``` - -### 6. 配置飞书机器人 - -#### 6.1 创建飞书应用 - -1. 访问 [飞书开发者平台](https://open.feishu.cn/) -2. 创建企业自建应用,添加「机器人」能力 -3. 获取 **AppID** 和 **AppSecret** -4. 开通权限: - - `im:message` - 发送消息 - - `im:message:readonly` - 读取消息 - - `im:resource` - 上传下载资源 -5. **事件订阅**:在「事件与回调」中,订阅 `im.message.receive_v1` 事件(接收用户消息) - - 订阅方式选择「长连接」(无需公网 IP) - - 或在「请求地址配置」中填写回调 URL(需要 HTTPS) - -#### 6.2 配置 bots.json - -在 `metabot/bots.json` 创建配置(与 myclaude 项目同级目录): - -```json -{ - "feishuBots": [ - { - "name": "myclaude", - "feishuAppId": "cli_xxxxx", - "feishuAppSecret": "xxxxxxxxxxxxx", - "defaultWorkingDirectory": "~/workspace" - } - ] -} -``` - -> ⚠️ **注意**:默认工作目录为 `~/workspace`,可根据需要修改。 - -> 💡 提示:飞书应用需要先发布,再开启「长连接」事件订阅 - ---- - -## 使用指南 - -### 首次使用 - -告诉 Claude: - -> "我是第一次使用,请引导式地问我一些问题,帮我建立个性化配置并更新到 CLAUDE.md 中。" - -这会帮你设置:身份背景、技术栈、沟通偏好、工作模式等。 - -### 日常交互 - -**方式 1:飞书对话** -- 在飞书中找到机器人 -- 直接发送消息即可对话 - -**方式 2:本地终端** - -```bash -myclaude # 默认启动 Claude Code,cwd = ~/workspace -myclaude --cwd . # 开发 CLI 时,cwd = 项目根目录 -``` - ---- - -## 进阶配置 - -### Skill 管理 - -**方式 1:让 Claude 帮你找** - -```bash -# 安装 find-skills -npx skills add -g shubhamsaboo/awesome-llm-apps@find-skills -/skill skills-sync -``` - -然后直接对话: -> "帮我找一个能处理 PDF 的 skill" 或 "我想找个前端设计的 skill" - -**方式 2:手动安装** - -```bash -# 项目级(仅当前项目) -mkdir -p .claude/skills/my-skill -cp SKILL.md .claude/skills/my-skill/ - -# 全局(所有项目) -mkdir -p ~/.claude/skills/my-skill -cp SKILL.md ~/.claude/skills/my-skill/ -``` - -**推荐 Skills:** - -| Skill | 用途 | 安装 | -|-------|------|------| -| `pdf` | PDF 处理 | `npx skills add -g anthropic/skills@pdf` | -| `metabot` | 飞书 API 调用 | `npx skills add -g xvirobotics/metabot@metabot` | -| `metamemory` | 共享知识库 | `npx skills add -g xvirobotics/metabot@metamemory` | -| `lark-doc` | 飞书文档操作 | `npx skills add -g xvirobotics/metabot@lark-doc` | -| `lark-im` | 飞书消息收发 | `npx skills add -g xvirobotics/metabot@lark-im` | -| `lark-calendar` | 飞书日历管理 | `npx skills add -g xvirobotics/metabot@lark-calendar` | -| `lark-task` | 飞书任务管理 | `npx skills add -g xvirobotics/metabot@lark-task` | -| `lark-vc` | 飞书会议记录 | `npx skills add -g xvirobotics/metabot@lark-vc` | -| `lark-drive` | 飞书云空间 | `npx skills add -g xvirobotics/metabot@lark-drive` | -| `lark-base` | 飞书多维表格 | `npx skills add -g xvirobotics/metabot@lark-base` | -| `lark-contact` | 飞书通讯录 | `npx skills add -g xvirobotics/metabot@lark-contact` | -| `lark-sheets` | 飞书电子表格 | `npx skills add -g xvirobotics/metabot@lark-sheets` | -| `lark-wiki` | 飞书知识库 | `npx skills add -g xvirobotics/metabot@lark-wiki` | -| `lark-approval` | 飞书审批 | `npx skills add -g xvirobotics/metabot@lark-approval` | -| `lark-whiteboard` | 飞书画板 | `npx skills add -g xvirobotics/metabot@lark-whiteboard` | -| `lark-event` | 飞书事件订阅 | `npx skills add -g xvirobotics/metabot@lark-event` | -| `lark-minutes` | 飞书妙记 | `npx skills add -g xvirobotics/metabot@lark-minutes` | -| `lark-mail` | 飞书邮箱 | `npx skills add -g xvirobotics/metabot@lark-mail` | - -> 💡 **安装方式**:在本地终端 `myclaude` 或飞书对话中,直接告诉 Claude: -> - "帮我安装处理 PDF 的 skill" -> - "安装飞书日历相关的 skills" -> - "我想用飞书文档功能,帮我安装" -> -> Claude 会自动帮你完成安装。 - -### Workspace 与资料管理 - -**创建项目工作区**(告诉 Claude 让它帮你操作): - -> "在 workspace 下创建 myproject 目录,并链接我的 Works 文件夹和 iCloud 知识库" - -Claude 会自动执行: -- 创建 `workspace/myproject/` -- 建立软连接到常用目录 -- 初始化项目结构 - -**Obsidian 集成**(可选): - -告诉 Claude: -> "帮我在 iCloud 创建 ClaudeWorkspace 文件夹,软连接到 workspace,并配置 Obsidian" - -优势: -- 📱 iPhone/iPad 随时查看 Claude 生成的内容 -- 📝 双向编辑(Obsidian ↔ Claude) -- 🔍 Obsidian 全文检索资料 - ---- - -## 常用指令 - -> 💡 **以下指令在飞书机器人对话中直接输入使用** - -| 指令 | 说明 | -|------|------| -| `/help` | 显示帮助 | -| `/reset` | 开启新对话(重置上下文) | -| `/stop` | 停止当前任务(卡住时用) | -| `/skills` | 列出可用 skills | -| `/memory` | 查看记忆内容 | - ---- - -## 常见问题 - -**Q1: 飞书回复 error 或卡住** -- 卡住时发送 `/stop` 停止当前任务 -- 想开启新对话发送 `/reset` - -**Q2: 切换模型** -```bash -cc-switch list -cc-switch use deepseek -``` - -**Q3: 飞书机器人不响应** -1. 检查 `metabot start` 是否启动 -2. 检查 `bots.json` 凭证是否正确 -3. 飞书应用是否已发布、长连接是否开启 - -**Q4: Windows 找不到 Git Bash** -在 `~/.claude/settings.json` 添加: -```json -{ - "env": { - "CLAUDE_CODE_GIT_BASH_PATH": "C:\\Program Files\\Git\\bin\\bash.exe" - } -} -``` - ---- - -## 维护与故障排查 - -### 更新升级 - -**Claude Code:** -```bash -claude update -``` - -**cc-switch:** -```bash -# macOS (Homebrew) -brew upgrade cc-switch - -# 其他平台:下载最新安装包重新安装 -``` - -**Metabot:** -```bash -metabot update -``` - -### 卸载 - -**Claude Code:** -```bash -claude uninstall -``` - -**cc-switch:** -```bash -# macOS -brew uninstall cc-switch - -# 其他平台:使用系统包管理器或删除安装文件 -``` - -**Metabot:** -```bash -rm -rf ~/.metabot -rm ~/.local/bin/metabot -``` - ---- - -## 附录:Git 代理加速(可选) - -`git-proxy-clone` 是一个本地自定义 skill,可在执行 git 命令时自动配置 FastGithub 代理,加速 GitHub 访问。 - -### 前置依赖 - -**安装 FastGithub** -- 下载地址:https://github.com/creazyboyone/FastGithub/releases -- 安装并启动 FastGithub(默认代理端口 `38457`) - -### 使用方式 - -#### 方式1:使用 Skill(推荐,自动代理) - -**安装 skill:** - -复制以下 prompt 发送给 Claude: - -``` -请帮我创建一个名为 git-proxy-clone 的本地 skill,用于自动配置 FastGithub 代理加速 GitHub 操作。 - -要求: -1. 路径:~/.claude/skills/git-proxy-clone/SKILL.md -2. 触发条件:用户执行 git clone、git pull、git fetch 或 npx skills add 时 -3. 代理地址:http://127.0.0.1:38457 -4. 自动行为: - - 执行 git 命令前自动设置代理(同时设置 http.proxy 和 https.proxy 为 http://127.0.0.1:38457) - - 命令完成后自动取消代理(同时 unset http.proxy 和 https.proxy) -5. 提供封装命令:/skill git-proxy-clone clone 、/skill git-proxy-clone skill 、/skill git-proxy-clone exec "" -6. 当用户直接输入 git clone 等命令时,skill 应自动拦截并包装执行(先设代理、执行命令、最后取消代理) - -请创建完整的 SKILL.md 文件。 -``` - -创建完成后,Claude Code 会自动识别并加载该 skill。 - -#### 方式2:手动配置(无需 skill) - -如果不想使用 skill,可手动配置 Git 别名: - -```bash -# 在 ~/.zshrc 或 ~/.bashrc 中添加: -alias git_proxy_set_fastgithub='\ - git config --global http.proxy http://127.0.0.1:38457 && \ - git config --global https.proxy http://127.0.0.1:38457' - -alias git_proxy_unset='\ - git config --global --unset http.proxy && \ - git config --global --unset https.proxy' -``` - -然后执行 `source ~/.zshrc` 使配置生效。 - -**使用时手动切换:** - -```bash -git_proxy_set_fastgithub # 设置代理 -git clone https://github.com/xxx/xxx.git -git_proxy_unset # 取消代理 -``` - ---- +如果你要部署的是**小荷助理**完整环境(含 Claude Code、cc-switch、Metabot、飞书机器人等),请参见 [xiaohe-agent 部署文档](../../README.md)。 ## License diff --git a/myagents/__init__.py b/myagents/__init__.py new file mode 100644 index 0000000..f315210 --- /dev/null +++ b/myagents/__init__.py @@ -0,0 +1 @@ +"""Myagents CLI package.""" diff --git a/myagents/__main__.py b/myagents/__main__.py new file mode 100644 index 0000000..3963647 --- /dev/null +++ b/myagents/__main__.py @@ -0,0 +1,3 @@ +from myagents.cli import main + +main() diff --git a/myagents/cli.py b/myagents/cli.py new file mode 100644 index 0000000..74f9ae8 --- /dev/null +++ b/myagents/cli.py @@ -0,0 +1,42 @@ +"""Myagents CLI entrypoint.""" + +from importlib.metadata import PackageNotFoundError, version + +import click + +from myagents.commands import update_cmd, upgrade_cmd +from myagents.launcher import build_cli + + +def _package_version() -> str: + try: + return version("myagents") + except PackageNotFoundError: + return "0.0.0" + + +@click.group(invoke_without_command=True) +@click.version_option(version=_package_version(), prog_name="myagents") +@click.pass_context +def cli(ctx: click.Context) -> None: + """Myagents: unified launcher for AI coding agents. + + Use ``myagents claude`` or ``myagents kimi`` to start an agent in workspace/. + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit(0) + + +cli.add_command(build_cli("claude"), name="claude") +cli.add_command(build_cli("kimi"), name="kimi") +cli.add_command(update_cmd) +cli.add_command(upgrade_cmd, name="upgrade") + + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/myclaude/commands/__init__.py b/myagents/commands/__init__.py similarity index 78% rename from myclaude/commands/__init__.py rename to myagents/commands/__init__.py index 7d94a15..318429b 100644 --- a/myclaude/commands/__init__.py +++ b/myagents/commands/__init__.py @@ -9,5 +9,5 @@ __all__ = ["update_cmd", "upgrade_cmd"] @click.command("upgrade") def upgrade_cmd() -> None: - """Alias for update: reinstall myclaude from this repository.""" + """Alias for update: reinstall myagents from this repository.""" return click.get_current_context().invoke(update_cmd) diff --git a/myclaude/commands/init.py b/myagents/commands/init.py similarity index 93% rename from myclaude/commands/init.py rename to myagents/commands/init.py index 2fe1574..2d55ec0 100644 --- a/myclaude/commands/init.py +++ b/myagents/commands/init.py @@ -6,7 +6,7 @@ from pathlib import Path from rich.console import Console -from myclaude.project_root import get_myclaude_project_root, get_workspace_root +from myagents.project_root import get_project_root, get_workspace_root console = Console() stderr_console = Console(stderr=True) @@ -139,18 +139,18 @@ def sync_workspace_links( 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): + # Handle myagents separately: use project root detection, not zshrc. + myagents_target = get_project_root() + if _sync_link(workspace, "myagents", myagents_target, dry_run, force): action = "would update" if dry_run else "updated" if verbose: console.print( - f"[green]myclaude: {action} -> {myclaude_target}[/green]" + f"[green]myagents: {action} -> {myagents_target}[/green]" ) changed += 1 else: if verbose: - console.print("[dim]myclaude: already correct[/dim]") + console.print("[dim]myagents: already correct[/dim]") for name, env_vars in _LINK_MAP.items(): target = _resolve_target(env_vars, zshrc) diff --git a/myclaude/commands/update.py b/myagents/commands/update.py similarity index 70% rename from myclaude/commands/update.py rename to myagents/commands/update.py index b8d7319..45c6e92 100644 --- a/myclaude/commands/update.py +++ b/myagents/commands/update.py @@ -1,4 +1,4 @@ -"""Reinstall myclaude from the local repo.""" +"""Reinstall myagents from the local repo.""" import subprocess import sys @@ -7,8 +7,8 @@ from pathlib import Path import click from rich.console import Console -from myclaude.commands.init import sync_workspace_links -from myclaude.project_root import get_myclaude_project_root +from myagents.commands.init import sync_workspace_links +from myagents.project_root import get_project_root console = Console() stderr_console = Console(stderr=True) @@ -29,17 +29,17 @@ def _run_pip_editable(root: Path) -> int: @click.command("update") def update_cmd() -> None: - """Reinstall myclaude and sync workspace symlinks.""" - root = get_myclaude_project_root() - fallback = Path.home() / ".myclaude" + """Reinstall myagents and sync workspace symlinks.""" + root = get_project_root() + fallback = Path.home() / ".myagents" if root == fallback: stderr_console.print( - "[red]Not inside myclaude repo.[/red] Set [cyan]MYCLAUDE_PROJECT_ROOT[/cyan] " + "[red]Not inside myagents repo.[/red] Set [cyan]MYAGENTS_PROJECT_ROOT[/cyan] " "or run from the clone.", ) raise SystemExit(1) - console.print("[bold cyan]Updating myclaude…[/bold cyan]") + console.print("[bold cyan]Updating myagents…[/bold cyan]") makefile = root / "Makefile" if makefile.is_file(): rc = _run_make_install(root) @@ -54,7 +54,7 @@ def update_cmd() -> None: if rc != 0: stderr_console.print("[red]Update failed.[/red]") raise SystemExit(rc) - console.print("[green]myclaude updated.[/green]") + console.print("[green]myagents updated.[/green]") console.print() console.print("[bold cyan]Syncing workspace links…[/bold cyan]") diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py new file mode 100644 index 0000000..e61f60f --- /dev/null +++ b/myagents/entrypoints.py @@ -0,0 +1,16 @@ +"""Standalone entrypoints for myclaude and mykimi command names.""" + +from myagents.launcher import build_cli + +claude_cli = build_cli("claude", prog_name="myclaude") +kimi_cli = build_cli("kimi", prog_name="mykimi") + + +def claude_main() -> None: + """Run ``myclaude``.""" + claude_cli() + + +def kimi_main() -> None: + """Run ``mykimi``.""" + kimi_cli() diff --git a/myagents/launcher.py b/myagents/launcher.py new file mode 100644 index 0000000..6132eae --- /dev/null +++ b/myagents/launcher.py @@ -0,0 +1,276 @@ +"""Shared launcher logic for AI coding agent CLIs.""" + +import hashlib +import json +import os +import re +import shutil +import subprocess +from datetime import datetime +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +import click +from rich.console import Console + +from myagents.project_root import get_workspace_root + +stderr_console = Console(stderr=True) +console = Console() + + +_BACKENDS: dict[str, dict] = { + "claude": { + "binary": "claude", + "env_bin": "CLAUDE_BIN", + "sessions_root": lambda: Path.home() / ".claude" / "projects", + "session_pattern": "*.jsonl", + "default_args": ["--dangerously-skip-permissions"], + "not_found_msg": ( + "[red]claude CLI not found in PATH.[/red] Install Claude Code or set " + "[cyan]CLAUDE_BIN[/cyan]." + ), + }, + "kimi": { + "binary": "kimi", + "env_bin": "KIMI_BIN", + "sessions_root": lambda: Path.home() / ".kimi" / "sessions", + "session_pattern": "*", + "default_args": [], + "not_found_msg": ( + "[red]kimi CLI not found in PATH.[/red] Install Kimi Code CLI or set " + "[cyan]KIMI_BIN[/cyan]." + ), + }, +} + + +def _resolve_chat_cwd(cwd: str | None) -> Path: + """Working directory: --cwd if given, else workspace/.""" + if cwd is None: + return get_workspace_root() + chat_cwd = Path(cwd).resolve() + if not chat_cwd.is_dir(): + stderr_console.print(f"[red]Not a directory:[/red] {cwd}") + raise SystemExit(1) + return chat_cwd + + +def _launch(backend: str, chat_cwd: Path, extra: list[str]) -> None: + """Run backend CLI in chat_cwd, forwarding extra args. Never returns.""" + config = _BACKENDS[backend] + binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"]) + if not binary: + stderr_console.print(config["not_found_msg"]) + raise SystemExit(127) + + cmd = [binary, *config["default_args"], *extra] + proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) + raise SystemExit(proc.returncode) + + +def _sessions_dir(backend: str, chat_cwd: Path) -> Path: + """Directory where the backend stores sessions for chat_cwd.""" + config = _BACKENDS[backend] + cwd_str = str(chat_cwd) + if backend == "kimi": + # Kimi hashes the cwd with md5. + munged = hashlib.md5(cwd_str.encode("utf-8")).hexdigest() # noqa: S324 + else: + munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str) + return config["sessions_root"]() / munged + + +def _first_prompt_claude(session_file: Path) -> str: + """Best-effort snippet of the first human prompt in a claude session log.""" + try: + with session_file.open(encoding="utf-8", errors="ignore") as fh: + for line in fh: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") != "user": + continue + content = entry.get("message", {}).get("content") + if isinstance(content, list): + content = " ".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + if isinstance(content, str) and content.strip(): + return " ".join(content.split())[:80] + except OSError: + pass + return "" + + +def _first_prompt_kimi(session_file: Path) -> str: + """Best-effort snippet of the first human prompt in a kimi context.jsonl.""" + try: + with session_file.open(encoding="utf-8", errors="ignore") as fh: + for line in fh: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("role") != "user": + continue + content = entry.get("content") + if isinstance(content, str) and content.strip(): + return " ".join(content.split())[:80] + except OSError: + pass + return "" + + +def _session_files(backend: str, chat_cwd: Path) -> list[Path]: + """Return session files/directories sorted by newest first.""" + config = _BACKENDS[backend] + sessions_dir = _sessions_dir(backend, chat_cwd) + + if backend == "claude": + files = sorted( + sessions_dir.glob(config["session_pattern"]), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + return [p for p in files if p.is_file()] + + # Kimi stores sessions as //context.jsonl + files: list[Path] = [] + for session_dir in sessions_dir.glob(config["session_pattern"]): + if not session_dir.is_dir(): + continue + context_file = session_dir / "context.jsonl" + if context_file.is_file(): + files.append(context_file) + files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return files + + +def _first_prompt(backend: str, session_file: Path) -> str: + if backend == "claude": + return _first_prompt_claude(session_file) + return _first_prompt_kimi(session_file) + + +def _resume_syntax(backend: str) -> str: + """Return the backend's resume flag syntax for use in help text.""" + if backend == "kimi": + return "--session, -S " + return "--resume, -r " + + +def _list_sessions(backend: str, chat_cwd: Path) -> None: + """Print resumable sessions for chat_cwd, newest first.""" + files = _session_files(backend, chat_cwd) + if not files: + stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}") + return + + console.print(f"[bold]Sessions in[/bold] {chat_cwd}") + for session_file in files: + mtime = datetime.fromtimestamp(session_file.stat().st_mtime) + snippet = _first_prompt(backend, session_file) or "[dim](empty)[/dim]" + session_id = session_file.parent.name if backend == "kimi" else session_file.stem + console.print( + f" [cyan]{session_id}[/cyan] " + f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" + ) + console.print( + f"\n[dim]Resume with[/dim] [green]{backend} {_resume_syntax(backend)}[/green] " + "[dim](add --cwd if not workspace).[/dim]" + ) + + +class LaunchGroup(click.Group): + """Group that forwards any non-subcommand invocation to the backend CLI. + + Unknown leading tokens (resume flags, ``--continue``, etc.) would otherwise + make click raise "No such command". Instead we stash the raw tokens and + route them to the hidden ``__run__`` command, which launches the backend + with them as passthrough. + """ + + def resolve_command(self, ctx, args): # type: ignore[override] + if args and not args[0].startswith("-") and args[0] in self.commands: + return super().resolve_command(ctx, args) + ctx.meta["passthrough"] = list(args) + run = self.get_command(ctx, "__run__") + assert run is not None + return run.name, run, [] + + +def build_cli(backend: str, prog_name: str | None = None) -> click.Group: + """Build a click CLI that wraps ``backend`` (claude or kimi). + + ``prog_name`` is used in --version output. When omitted it defaults to + ``myagents `` (suitable for use as a subcommand of ``myagents``). + """ + backend_title = backend.capitalize() + + @click.group( + cls=LaunchGroup, + invoke_without_command=True, + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + }, + ) + @click.option( + "--cwd", + "-C", + is_flag=False, + flag_value=".", + default=None, + type=click.Path(dir_okay=True, file_okay=False), + help="Use specified path as working directory (default: workspace/).", + ) + @click.option( + "--list", + "-l", + "list_sessions", + is_flag=True, + default=False, + help=f"List resumable {backend} sessions for the working directory and exit.", + ) + @click.pass_context + def cli(ctx: click.Context, cwd: str | None, list_sessions: bool) -> None: + """Launcher entrypoint; full help is set on the group below.""" + if ctx.invoked_subcommand not in (None, "__run__"): + return + + chat_cwd = _resolve_chat_cwd(cwd) + + if list_sessions: + _list_sessions(backend, chat_cwd) + raise SystemExit(0) + + if ctx.invoked_subcommand is None: + _launch(backend, chat_cwd, []) + + @cli.command(name="__run__", hidden=True) + @click.pass_context + def _run(ctx: click.Context) -> None: + """Hidden passthrough target: launch backend with stashed raw args.""" + parent = ctx.parent + assert parent is not None + chat_cwd = _resolve_chat_cwd(parent.params.get("cwd")) + extra = list(ctx.meta.get("passthrough", [])) + _launch(backend, chat_cwd, extra) + + cli.help = ( + f"Launch {backend_title} Code in workspace/.\n\n" + f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}." + ) + try: + pkg_version = version("myagents") + except PackageNotFoundError: + pkg_version = "0.0.0" + click.version_option( + version=pkg_version, + prog_name=prog_name or f"myagents {backend}", + )(cli) + return cli diff --git a/myclaude/project_root.py b/myagents/project_root.py similarity index 65% rename from myclaude/project_root.py rename to myagents/project_root.py index afcfc22..aed768e 100644 --- a/myclaude/project_root.py +++ b/myagents/project_root.py @@ -1,4 +1,4 @@ -"""Resolve myclaude repository and workspace roots.""" +"""Resolve myagents repository and workspace roots.""" import os from pathlib import Path @@ -7,19 +7,19 @@ _MAX_WALK_DEPTH = 16 _MAX_SOURCE_DEPTH = 8 -def _pyproject_names_myclaude(path: Path) -> bool: +def _pyproject_names_myagents(path: Path) -> bool: try: text = path.read_text(encoding="utf-8", errors="ignore") except OSError: return False - return 'name = "myclaude"' in text or "name = 'myclaude'" in text + return 'name = "myagents"' in text or "name = 'myagents'" in text def _walk_up_for_pyproject(start: Path) -> Path | None: p = start.resolve() for _ in range(_MAX_WALK_DEPTH): candidate = p / "pyproject.toml" - if candidate.is_file() and _pyproject_names_myclaude(candidate): + if candidate.is_file() and _pyproject_names_myagents(candidate): return p parent = p.parent if parent == p: @@ -28,13 +28,13 @@ def _walk_up_for_pyproject(start: Path) -> Path | None: return None -def get_myclaude_project_root() -> Path: +def get_project_root() -> Path: """ - Root of the myclaude repo (contains Makefile + pyproject). + Root of the myagents repo (contains Makefile + pyproject). - Order: MYCLAUDE_PROJECT_ROOT > walk from cwd > package source tree > ~/.myclaude + Order: MYAGENTS_PROJECT_ROOT > walk from cwd > package source tree > ~/.myagents """ - env_root = os.environ.get("MYCLAUDE_PROJECT_ROOT") + env_root = os.environ.get("MYAGENTS_PROJECT_ROOT") if env_root: return Path(env_root).expanduser().resolve() @@ -50,27 +50,27 @@ def get_myclaude_project_root() -> Path: here = Path(__file__).resolve().parent for _ in range(_MAX_SOURCE_DEPTH): pyproject = here / "pyproject.toml" - if pyproject.is_file() and _pyproject_names_myclaude(pyproject): + if pyproject.is_file() and _pyproject_names_myagents(pyproject): return here if here.parent == here: break here = here.parent - return Path.home() / ".myclaude" + return Path.home() / ".myagents" def get_workspace_root() -> Path: """ - Root of the myclaude workspace directory. + Root of the myagents workspace directory. - Order: MYCLAUDE_WORKSPACE_ROOT > project_root/workspace/ > ~/workspace + Order: MYAGENTS_WORKSPACE_ROOT > project_root/workspace/ > ~/workspace Creates the directory if it does not exist. """ - env_root = os.environ.get("MYCLAUDE_WORKSPACE_ROOT") + env_root = os.environ.get("MYAGENTS_WORKSPACE_ROOT") if env_root: root = Path(env_root).expanduser().resolve() else: - project_root = get_myclaude_project_root() + project_root = get_project_root() project_workspace = project_root / "workspace" if project_workspace.is_dir(): root = project_workspace diff --git a/myclaude/__init__.py b/myclaude/__init__.py deleted file mode 100644 index 5cc26e6..0000000 --- a/myclaude/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Myclaude CLI package.""" diff --git a/myclaude/__main__.py b/myclaude/__main__.py deleted file mode 100644 index 501c22d..0000000 --- a/myclaude/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from myclaude.cli import main - -main() diff --git a/myclaude/cli.py b/myclaude/cli.py deleted file mode 100644 index 2786425..0000000 --- a/myclaude/cli.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Myclaude CLI entrypoint.""" - -import json -import os -import re -import shutil -import subprocess -from datetime import datetime -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path - -import click -from rich.console import Console - -from myclaude.commands import update_cmd, upgrade_cmd -from myclaude.project_root import get_workspace_root - -stderr_console = Console(stderr=True) -console = Console() - - -def _package_version() -> str: - try: - return version("myclaude") - except PackageNotFoundError: - return "0.0.0" - - -def _resolve_chat_cwd(cwd: str | None) -> Path: - """Working directory for claude: --cwd if given, else workspace/.""" - if cwd is None: - return get_workspace_root() - chat_cwd = Path(cwd).resolve() - if not chat_cwd.is_dir(): - stderr_console.print(f"[red]Not a directory:[/red] {cwd}") - raise SystemExit(1) - return chat_cwd - - -def _launch(chat_cwd: Path, extra: list[str]) -> None: - """Run claude in chat_cwd, forwarding extra args. Never returns.""" - binary = os.environ.get("CLAUDE_BIN") or shutil.which("claude") - if not binary: - stderr_console.print( - "[red]claude CLI not found in PATH.[/red] Install Claude Code or set " - "[cyan]CLAUDE_BIN[/cyan].", - ) - raise SystemExit(127) - - cmd = [binary, "--dangerously-skip-permissions", *extra] - proc = subprocess.run(cmd, cwd=str(chat_cwd), check=False) - raise SystemExit(proc.returncode) - - -def _sessions_dir(chat_cwd: Path) -> Path: - """Claude stores session logs under ~/.claude/projects//.""" - munged = re.sub(r"[^A-Za-z0-9]", "-", str(chat_cwd)) - return Path.home() / ".claude" / "projects" / munged - - -def _first_prompt(session_file: Path) -> str: - """Best-effort snippet of the first human prompt in a session log.""" - try: - with session_file.open(encoding="utf-8", errors="ignore") as fh: - for line in fh: - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - if entry.get("type") != "user": - continue - content = entry.get("message", {}).get("content") - if isinstance(content, list): - content = " ".join( - block.get("text", "") - for block in content - if isinstance(block, dict) - and block.get("type") == "text" - ) - if isinstance(content, str) and content.strip(): - return " ".join(content.split())[:80] - except OSError: - pass - return "" - - -def _list_sessions(chat_cwd: Path) -> None: - """Print resumable claude sessions for chat_cwd, newest first.""" - files = sorted( - _sessions_dir(chat_cwd).glob("*.jsonl"), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - if not files: - stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}") - return - - console.print(f"[bold]Sessions in[/bold] {chat_cwd}") - for session_file in files: - mtime = datetime.fromtimestamp(session_file.stat().st_mtime) - snippet = _first_prompt(session_file) or "[dim](empty)[/dim]" - console.print( - f" [cyan]{session_file.stem}[/cyan] " - f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet}" - ) - console.print( - "\n[dim]Resume with[/dim] [green]myclaude -r [/green] " - "[dim](add --cwd if not workspace).[/dim]" - ) - - -class LaunchGroup(click.Group): - """Group that forwards any non-subcommand invocation to claude. - - Unknown leading tokens (``--resume``, ``-r ``, ``--continue`` …) would - otherwise make click raise "No such command". Instead we stash the raw - tokens and route them to the hidden ``__run__`` command, which launches - claude with them as passthrough. - """ - - def resolve_command(self, ctx, args): # type: ignore[override] - if args and not args[0].startswith("-") and args[0] in self.commands: - return super().resolve_command(ctx, args) - ctx.meta["passthrough"] = list(args) - run = self.get_command(ctx, "__run__") - assert run is not None - return run.name, run, [] - - -@click.group( - cls=LaunchGroup, - invoke_without_command=True, - context_settings={ - "ignore_unknown_options": True, - "allow_extra_args": True, - }, -) -@click.version_option(version=_package_version()) -@click.option( - "--cwd", - "-C", - is_flag=False, - flag_value=".", - default=None, - type=click.Path(dir_okay=True, file_okay=False), - help="Use specified path as working directory (default: workspace/).", -) -@click.option( - "--list", - "-l", - "list_sessions", - is_flag=True, - default=False, - help="List resumable claude sessions for the working directory and exit.", -) -@click.pass_context -def cli(ctx: click.Context, cwd: str | None, list_sessions: bool) -> None: - """Myclaude: CLI toolkit. - - Run without subcommands to start Claude Code in workspace/. - Unknown arguments (--resume, -r , --continue, …) pass through to claude. - """ - # Real subcommands (update / upgrade) handle themselves. - if ctx.invoked_subcommand not in (None, "__run__"): - return - - chat_cwd = _resolve_chat_cwd(cwd) - - if list_sessions: - _list_sessions(chat_cwd) - raise SystemExit(0) - - # Bare `myclaude`: launch directly. The `__run__` branch handles passthrough. - if ctx.invoked_subcommand is None: - _launch(chat_cwd, []) - - -@cli.command(name="__run__", hidden=True) -@click.pass_context -def _run(ctx: click.Context) -> None: - """Hidden passthrough target: launch claude with stashed raw args.""" - parent = ctx.parent - assert parent is not None - chat_cwd = _resolve_chat_cwd(parent.params.get("cwd")) - extra = list(ctx.meta.get("passthrough", [])) - _launch(chat_cwd, extra) - - -cli.add_command(update_cmd) -cli.add_command(upgrade_cmd, name="upgrade") - - -def main() -> None: - cli() - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 1fafc7d..71b48db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "myclaude" +name = "myagents" version = "0.1.0" -description = "Myclaude CLI: package update and Claude Code launcher for this repo" +description = "Myagents CLI: unified launcher for AI coding agents" requires-python = ">=3.10" dependencies = [ "click>=8.3.2", @@ -9,7 +9,9 @@ dependencies = [ ] [project.scripts] -myclaude = "myclaude.cli:cli" +myagents = "myagents.cli:cli" +myclaude = "myagents.entrypoints:claude_main" +mykimi = "myagents.entrypoints:kimi_main" [dependency-groups] dev = ["pytest>=8.0"] @@ -23,7 +25,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["myclaude"] +packages = ["myagents"] [tool.black] line-length = 80 diff --git a/scripts/install_completion.sh b/scripts/install_completion.sh index 90a9f28..4490532 100755 --- a/scripts/install_completion.sh +++ b/scripts/install_completion.sh @@ -1,46 +1,64 @@ #!/bin/bash -# Install shell completions for myclaude to user directory only -# Usage: install_completion.sh +# Install shell completions for myagents, myclaude, mykimi to user directory only +# Usage: install_completion.sh -VENV_MYCLAUDE="$1" +VENV_BIN_DIR="$1" COMP_DIR="$2" -if [[ -z "$VENV_MYCLAUDE" || -z "$COMP_DIR" ]]; then - echo "Usage: $0 " >&2 +if [[ -z "$VENV_BIN_DIR" || -z "$COMP_DIR" ]]; then + echo "Usage: $0 " >&2 exit 1 fi USER_SHELL=$(basename "$SHELL") +# Portable uppercase helper (macOS bash 3.2 lacks ${var^^}). +_upcase() { + printf '%s' "$1" | tr '[:lower:]' '[:upper:]' +} + install_zsh_completion() { + local cmd="$1" + local out="$2" + local var="_$(_upcase "$cmd")_COMPLETE" mkdir -p "$COMP_DIR" 2>/dev/null || { echo "Error: cannot create $COMP_DIR" >&2; return 1; } - if _MYCLAUDE_COMPLETE=zsh_source "$VENV_MYCLAUDE" > "$COMP_DIR/_myclaude" 2>/dev/null; then - echo "Installed zsh completion: $COMP_DIR/_myclaude" + if eval "$var=zsh_source ${VENV_BIN_DIR}/${cmd}" > "$out" 2>/dev/null; then + echo "Installed zsh completion: $out" return 0 else - echo "Warning: failed to generate zsh completion" >&2 + echo "Warning: failed to generate zsh completion for $cmd" >&2 return 1 fi } install_bash_completion() { + local cmd="$1" + local out="$2" + local var="_$(_upcase "$cmd")_COMPLETE" mkdir -p "$COMP_DIR" 2>/dev/null || { echo "Error: cannot create $COMP_DIR" >&2; return 1; } - if _MYCLAUDE_COMPLETE=bash_source "$VENV_MYCLAUDE" > "$COMP_DIR/myclaude.bash" 2>/dev/null; then - echo "Installed bash completion: $COMP_DIR/myclaude.bash" + if eval "$var=bash_source ${VENV_BIN_DIR}/${cmd}" > "$out" 2>/dev/null; then + echo "Installed bash completion: $out" return 0 else - echo "Warning: failed to generate bash completion" >&2 + echo "Warning: failed to generate bash completion for $cmd" >&2 return 1 fi } if [[ "$USER_SHELL" == "zsh" ]]; then - install_zsh_completion + install_zsh_completion myagents "$COMP_DIR/_myagents" + install_zsh_completion myclaude "$COMP_DIR/_myclaude" + install_zsh_completion mykimi "$COMP_DIR/_mykimi" elif [[ "$USER_SHELL" == "bash" ]]; then - install_bash_completion + install_bash_completion myagents "$COMP_DIR/myagents.bash" + install_bash_completion myclaude "$COMP_DIR/myclaude.bash" + install_bash_completion mykimi "$COMP_DIR/mykimi.bash" else echo "Shell '$USER_SHELL' is not supported for automatic completion installation." echo "To install completions manually, run one of the following commands:" - echo " zsh: _MYCLAUDE_COMPLETE=zsh_source $VENV_MYCLAUDE > /path/to/completions/_myclaude" - echo " bash: _MYCLAUDE_COMPLETE=bash_source $VENV_MYCLAUDE > /path/to/completions/myclaude.bash" + for cmd in myagents myclaude mykimi; do + var="_$(_upcase "$cmd")_COMPLETE" + echo " zsh: $var=zsh_source ${VENV_BIN_DIR}/${cmd} > /path/to/completions/_${cmd}" + echo " bash: $var=bash_source ${VENV_BIN_DIR}/${cmd} > /path/to/completions/${cmd}.bash" + done fi diff --git a/scripts/rm_user_local_myagents.py b/scripts/rm_user_local_myagents.py new file mode 100644 index 0000000..e050ab8 --- /dev/null +++ b/scripts/rm_user_local_myagents.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Remove ~/.local/bin/{myagents,myclaude,mykimi} symlinks to this repo's venv.""" + +from __future__ import annotations + +import os +import sys + +_COMMANDS = ("myagents", "myclaude", "mykimi") + + +def _remove_link(link: str, want: str) -> bool: + """Remove link if it is a symlink pointing to want. Returns True if removed.""" + if not os.path.islink(link): + return False + + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(os.path.dirname(link), target) + target = os.path.normpath(target) + + if target == want: + os.unlink(link) + print("Removed", link) + return True + + print("Skip:", link, "points to", target, "(not this repo's", want + ")") + return False + + +def main() -> int: + root = os.path.normpath(os.environ.get("ROOT_DIR", os.getcwd())) + bin_dir = os.path.expanduser("~/.local/bin") + removed = 0 + skipped = 0 + + for command in _COMMANDS: + want = os.path.normpath(os.path.join(root, ".venv", "bin", command)) + link = os.path.join(bin_dir, command) + if _remove_link(link, want): + removed += 1 + elif os.path.lexists(link): + print("Skip:", link, "is not a symlink, leaving untouched") + skipped += 1 + + if removed == 0 and skipped == 0: + print("Nothing to remove") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/rm_user_local_myclaude.py b/scripts/rm_user_local_myclaude.py deleted file mode 100644 index ad0a4b6..0000000 --- a/scripts/rm_user_local_myclaude.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -"""Remove ~/.local/bin/myclaude if it is a symlink to this repo's .venv/bin/myclaude.""" - -from __future__ import annotations - -import os -import sys - - -def main() -> int: - root = os.path.normpath(os.environ.get("ROOT_DIR", os.getcwd())) - link = os.path.expanduser("~/.local/bin/myclaude") - want = os.path.normpath(os.path.join(root, ".venv", "bin", "myclaude")) - - if not os.path.islink(link): - if os.path.lexists(link): - print("Skip: not a symlink, leaving untouched") - else: - print("Nothing to remove") - return 0 - - target = os.readlink(link) - if not os.path.isabs(target): - target = os.path.join(os.path.dirname(link), target) - target = os.path.normpath(target) - - if target == want: - os.unlink(link) - print("Removed", link) - return 0 - - print("Skip: points to", target, "(not this repo's", want + ")") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4786a6c..ee25c11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,23 +1,25 @@ -"""Tests for myclaude.cli.""" +"""Tests for myagents CLI.""" from pathlib import Path from unittest.mock import MagicMock, patch from click.testing import CliRunner -from myclaude.cli import cli +from myagents.cli import cli -class TestCliHelp: - """Tests for CLI help and basic invocation.""" +class TestMyagentsHelp: + """Tests for top-level myagents command.""" - def test_help_shows_options(self) -> None: - """--help should show cwd option only.""" + def test_help_shows_agent_subcommands(self) -> None: + """--help should list claude and kimi subcommands.""" runner = CliRunner() result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 - assert "--cwd" in result.output - assert "--dangerously-skip-permissions" not in result.output + assert "claude" in result.output + assert "kimi" in result.output + assert "update" in result.output + assert "upgrade" in result.output def test_version_shows_version(self) -> None: """--version should show package version.""" @@ -26,8 +28,192 @@ class TestCliHelp: assert result.exit_code == 0 assert "version" in result.output.lower() + def test_bare_invocation_shows_help(self) -> None: + """Running myagents without subcommands should show help, not launch agent.""" + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "Usage:" in result.output + + +class TestClaudeSubcommand: + """Tests for ``myagents claude``.""" + + def test_help_shows_options(self) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["claude", "--help"]) + assert result.exit_code == 0 + assert "--cwd" in result.output + assert "--list" in result.output + assert "--dangerously-skip-permissions" not in result.output + + def test_runs_claude(self) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["claude"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == [ + "/usr/bin/claude", + "--dangerously-skip-permissions", + ] + + def test_missing_binary_error(self) -> None: + runner = CliRunner() + with patch("myagents.launcher.shutil.which", return_value=None): + result = runner.invoke(cli, ["claude"]) + assert result.exit_code == 127 + assert "not found" in result.output.lower() + + def test_cwd_option_passed(self, tmp_path: Path) -> None: + runner = CliRunner() + test_dir = tmp_path / "test_cwd" + test_dir.mkdir() + + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["claude", "--cwd", str(test_dir)]) + assert result.exit_code == 0 + assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve()) + + def test_cwd_invalid_directory(self, tmp_path: Path) -> None: + runner = CliRunner() + bad_dir = tmp_path / "does_not_exist" + result = runner.invoke(cli, ["claude", "--cwd", str(bad_dir)]) + assert result.exit_code == 1 + assert "not a directory" in result.output.lower() + + +class TestClaudePassthrough: + """Unknown leading flags forward to claude instead of erroring.""" + + def _invoke(self, args: list[str], tmp_path: Path) -> list[str]: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke( + cli, ["claude", "--cwd", str(tmp_path), *args] + ) + assert result.exit_code == 0, result.output + mock_run.assert_called_once() + return mock_run.call_args[0][0] + + def test_resume_flag_passes_through(self, tmp_path: Path) -> None: + cmd = self._invoke(["--resume"], tmp_path) + assert cmd == [ + "/usr/bin/claude", + "--dangerously-skip-permissions", + "--resume", + ] + + def test_resume_with_session_id(self, tmp_path: Path) -> None: + cmd = self._invoke(["-r", "abc123"], tmp_path) + assert cmd[-2:] == ["-r", "abc123"] + + def test_continue_flag_passes_through(self, tmp_path: Path) -> None: + cmd = self._invoke(["--continue"], tmp_path) + assert cmd[-1] == "--continue" + + +class TestClaudeListSessions: + """``myagents claude --list`` reports resumable sessions.""" + + def test_list_empty_directory(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch("myagents.launcher.subprocess.run") as mock_run: + result = runner.invoke(cli, ["claude", "--cwd", str(tmp_path), "--list"]) + assert result.exit_code == 0 + assert "no sessions" in result.output.lower() + mock_run.assert_not_called() + + +class TestKimiSubcommand: + """Tests for ``myagents kimi``.""" + + def test_help_shows_options(self) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["kimi", "--help"]) + assert result.exit_code == 0 + assert "--cwd" in result.output + assert "--list" in result.output + assert "--dangerously-skip-permissions" not in result.output + + def test_runs_kimi(self) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["kimi"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["/usr/bin/kimi"] + + def test_missing_binary_error(self) -> None: + runner = CliRunner() + with patch("myagents.launcher.shutil.which", return_value=None): + result = runner.invoke(cli, ["kimi"]) + assert result.exit_code == 127 + assert "not found" in result.output.lower() + + def test_cwd_option_passed(self, tmp_path: Path) -> None: + runner = CliRunner() + test_dir = tmp_path / "test_cwd" + test_dir.mkdir() + + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["kimi", "--cwd", str(test_dir)]) + assert result.exit_code == 0 + assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve()) + + +class TestKimiPassthrough: + """Unknown leading flags forward to kimi instead of erroring.""" + + def _invoke(self, args: list[str], tmp_path: Path) -> list[str]: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["kimi", "--cwd", str(tmp_path), *args]) + assert result.exit_code == 0, result.output + mock_run.assert_called_once() + return mock_run.call_args[0][0] + + def test_resume_flag_passes_through(self, tmp_path: Path) -> None: + cmd = self._invoke(["--resume"], tmp_path) + assert cmd == ["/usr/bin/kimi", "--resume"] + + def test_resume_with_session_id(self, tmp_path: Path) -> None: + cmd = self._invoke(["-r", "abc123"], tmp_path) + assert cmd[-2:] == ["-r", "abc123"] + + def test_continue_flag_passes_through(self, tmp_path: Path) -> None: + cmd = self._invoke(["--continue"], tmp_path) + assert cmd[-1] == "--continue" + + +class TestUpdateSubcommand: + """Tests for update/upgrade subcommands registration.""" + def test_update_subcommand_exists(self) -> None: - """update subcommand should be registered.""" runner = CliRunner() result = runner.invoke(cli, ["update", "--help"]) assert result.exit_code == 0 @@ -37,111 +223,6 @@ class TestCliHelp: ) def test_upgrade_alias_exists(self) -> None: - """upgrade should be an alias for update.""" runner = CliRunner() result = runner.invoke(cli, ["upgrade", "--help"]) assert result.exit_code == 0 - - -class TestCliDefaultBehavior: - """Tests for default chat behavior.""" - - def test_no_subcommand_runs_claude(self) -> None: - """Running without subcommand should invoke claude binary with skip-permissions.""" - runner = CliRunner() - - with ( - patch("myclaude.cli.shutil.which", return_value="/usr/bin/claude"), - patch("myclaude.cli.subprocess.run") as mock_run, - ): - mock_run.return_value = MagicMock(returncode=0) - result = runner.invoke(cli, []) - assert result.exit_code == 0 - mock_run.assert_called_once() - call_args = mock_run.call_args - assert call_args[0][0] == [ - "/usr/bin/claude", - "--dangerously-skip-permissions", - ] - - def test_missing_claude_binary_error(self) -> None: - """Should exit with error when claude binary not found.""" - runner = CliRunner() - - with patch("myclaude.cli.shutil.which", return_value=None): - result = runner.invoke(cli, []) - assert result.exit_code == 127 - assert "not found" in result.output.lower() - - def test_cwd_option_passed(self, tmp_path: Path) -> None: - """--cwd should be passed as working directory.""" - runner = CliRunner() - test_dir = tmp_path / "test_cwd" - test_dir.mkdir() - - with ( - patch("myclaude.cli.shutil.which", return_value="/usr/bin/claude"), - patch("myclaude.cli.subprocess.run") as mock_run, - ): - mock_run.return_value = MagicMock(returncode=0) - result = runner.invoke(cli, ["--cwd", str(test_dir)]) - assert result.exit_code == 0 - call_kwargs = mock_run.call_args.kwargs - assert call_kwargs.get("cwd") == str(test_dir.resolve()) - - def test_cwd_invalid_directory(self, tmp_path: Path) -> None: - """--cwd pointing to non-existent directory should error.""" - runner = CliRunner() - bad_dir = tmp_path / "does_not_exist" - - result = runner.invoke(cli, ["--cwd", str(bad_dir)]) - assert result.exit_code == 1 - assert "not a directory" in result.output.lower() - - -class TestCliPassthrough: - """Unknown leading flags forward to claude instead of erroring.""" - - def _invoke(self, args: list[str], tmp_path: Path) -> list[str]: - """Run cli with mocked claude and return the command claude was called with.""" - runner = CliRunner() - with ( - patch("myclaude.cli.shutil.which", return_value="/usr/bin/claude"), - patch("myclaude.cli.subprocess.run") as mock_run, - ): - mock_run.return_value = MagicMock(returncode=0) - result = runner.invoke(cli, ["--cwd", str(tmp_path), *args]) - assert result.exit_code == 0, result.output - mock_run.assert_called_once() - return mock_run.call_args[0][0] - - def test_resume_flag_passes_through(self, tmp_path: Path) -> None: - """`--resume` must reach claude, not be parsed as a subcommand.""" - cmd = self._invoke(["--resume"], tmp_path) - assert cmd == [ - "/usr/bin/claude", - "--dangerously-skip-permissions", - "--resume", - ] - - def test_resume_with_session_id(self, tmp_path: Path) -> None: - """`-r ` forwards both the flag and its value.""" - cmd = self._invoke(["-r", "abc123"], tmp_path) - assert cmd[-2:] == ["-r", "abc123"] - - def test_continue_flag_passes_through(self, tmp_path: Path) -> None: - cmd = self._invoke(["--continue"], tmp_path) - assert cmd[-1] == "--continue" - - -class TestCliListSessions: - """`--list` reports resumable sessions without launching claude.""" - - def test_list_empty_directory(self, tmp_path: Path) -> None: - """A directory with no recorded sessions reports none and exits 0.""" - runner = CliRunner() - with patch("myclaude.cli.subprocess.run") as mock_run: - result = runner.invoke(cli, ["--cwd", str(tmp_path), "--list"]) - assert result.exit_code == 0 - assert "no sessions" in result.output.lower() - mock_run.assert_not_called() diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py new file mode 100644 index 0000000..5ec71cd --- /dev/null +++ b/tests/test_entrypoints.py @@ -0,0 +1,75 @@ +"""Tests for standalone myclaude / mykimi entrypoints.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from myagents.entrypoints import claude_cli, kimi_cli + + +class TestMyclaudeEntrypoint: + """``myclaude`` standalone entrypoint.""" + + def test_runs_claude(self) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(claude_cli, []) + assert result.exit_code == 0 + assert mock_run.call_args[0][0] == [ + "/usr/bin/claude", + "--dangerously-skip-permissions", + ] + + def test_version_shows_myclaude(self) -> None: + runner = CliRunner() + result = runner.invoke(claude_cli, ["--version"]) + assert result.exit_code == 0 + assert "myclaude" in result.output + + def test_passthrough(self, tmp_path: Path) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"]) + assert result.exit_code == 0 + assert "--resume" in mock_run.call_args[0][0] + + +class TestMykimiEntrypoint: + """``mykimi`` standalone entrypoint.""" + + def test_runs_kimi(self) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(kimi_cli, []) + assert result.exit_code == 0 + assert mock_run.call_args[0][0] == ["/usr/bin/kimi"] + + def test_version_shows_mykimi(self) -> None: + runner = CliRunner() + result = runner.invoke(kimi_cli, ["--version"]) + assert result.exit_code == 0 + assert "mykimi" in result.output + + def test_passthrough(self, tmp_path: Path) -> None: + runner = CliRunner() + with ( + patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"), + patch("myagents.launcher.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(kimi_cli, ["--cwd", str(tmp_path), "--resume"]) + assert result.exit_code == 0 + assert "--resume" in mock_run.call_args[0][0] diff --git a/tests/test_project_root.py b/tests/test_project_root.py index 6887f57..590dcbe 100644 --- a/tests/test_project_root.py +++ b/tests/test_project_root.py @@ -1,65 +1,65 @@ -"""Tests for myclaude.project_root.""" +"""Tests for myagents.project_root.""" import os from pathlib import Path from unittest.mock import patch -from myclaude.project_root import get_myclaude_project_root, get_workspace_root +from myagents.project_root import get_project_root, get_workspace_root -class TestGetMyclaudeProjectRoot: - """Tests for get_myclaude_project_root.""" +class TestGetProjectRoot: + """Tests for get_project_root.""" def test_env_var_takes_priority(self, tmp_path: Path) -> None: - """MYCLAUDE_PROJECT_ROOT env var should be used when set.""" + """MYAGENTS_PROJECT_ROOT env var should be used when set.""" fake_root = tmp_path / "fake_repo" fake_root.mkdir() - (fake_root / "pyproject.toml").write_text('name = "myclaude"\n') + (fake_root / "pyproject.toml").write_text('name = "myagents"\n') - with patch.dict(os.environ, {"MYCLAUDE_PROJECT_ROOT": str(fake_root)}): - result = get_myclaude_project_root() + with patch.dict(os.environ, {"MYAGENTS_PROJECT_ROOT": str(fake_root)}): + result = get_project_root() assert result == fake_root.resolve() def test_env_var_expands_tilde(self, tmp_path: Path) -> None: - """MYCLAUDE_PROJECT_ROOT should expand ~ to home directory.""" + """MYAGENTS_PROJECT_ROOT should expand ~ to home directory.""" home = tmp_path / "home" home.mkdir() fake_root = home / "fake_repo" fake_root.mkdir() - (fake_root / "pyproject.toml").write_text('name = "myclaude"\n') + (fake_root / "pyproject.toml").write_text('name = "myagents"\n') with patch.dict( os.environ, { - "MYCLAUDE_PROJECT_ROOT": "~/fake_repo", + "MYAGENTS_PROJECT_ROOT": "~/fake_repo", "HOME": str(home), }, ): - result = get_myclaude_project_root() + result = get_project_root() assert result == fake_root.resolve() def test_fallback_when_not_in_repo(self) -> None: - """When not in a repo, fallback to ~/.myclaude.""" + """When not in a repo, fallback to ~/.myagents.""" with ( patch.dict(os.environ, {}, clear=True), patch("pathlib.Path.cwd", side_effect=OSError), patch( - "myclaude.project_root._pyproject_names_myclaude", + "myagents.project_root._pyproject_names_myagents", return_value=False, ), ): - result = get_myclaude_project_root() - assert result == Path.home() / ".myclaude" + result = get_project_root() + assert result == Path.home() / ".myagents" class TestGetWorkspaceRoot: """Tests for get_workspace_root.""" def test_env_var_takes_priority(self, tmp_path: Path) -> None: - """MYCLAUDE_WORKSPACE_ROOT env var should be used when set.""" + """MYAGENTS_WORKSPACE_ROOT env var should be used when set.""" custom = tmp_path / "custom_workspace" - with patch.dict(os.environ, {"MYCLAUDE_WORKSPACE_ROOT": str(custom)}): + with patch.dict(os.environ, {"MYAGENTS_WORKSPACE_ROOT": str(custom)}): result = get_workspace_root() assert result == custom.resolve() assert result.is_dir() @@ -69,21 +69,21 @@ class TestGetWorkspaceRoot: new_ws = tmp_path / "new_workspace" assert not new_ws.exists() - with patch.dict(os.environ, {"MYCLAUDE_WORKSPACE_ROOT": str(new_ws)}): + with patch.dict(os.environ, {"MYAGENTS_WORKSPACE_ROOT": str(new_ws)}): result = get_workspace_root() assert result == new_ws.resolve() assert result.is_dir() def test_defaults_to_project_workspace(self, tmp_path: Path) -> None: """When project_root/workspace exists, use it.""" - project_root = tmp_path / "myclaude" + project_root = tmp_path / "myagents" project_root.mkdir() workspace = project_root / "workspace" workspace.mkdir() - (project_root / "pyproject.toml").write_text('name = "myclaude"\n') + (project_root / "pyproject.toml").write_text('name = "myagents"\n') with patch.dict( - os.environ, {"MYCLAUDE_PROJECT_ROOT": str(project_root)}, clear=True + os.environ, {"MYAGENTS_PROJECT_ROOT": str(project_root)}, clear=True ): result = get_workspace_root() assert result == workspace.resolve() @@ -95,8 +95,8 @@ class TestGetWorkspaceRoot: with ( patch.dict(os.environ, {}, clear=True), patch( - "myclaude.project_root.get_myclaude_project_root", - return_value=tmp_path / ".myclaude", + "myagents.project_root.get_project_root", + return_value=tmp_path / ".myagents", ), patch("pathlib.Path.home", return_value=home), ): diff --git a/uv.lock b/uv.lock index e343897..507e78e 100644 --- a/uv.lock +++ b/uv.lock @@ -66,7 +66,7 @@ wheels = [ ] [[package]] -name = "myclaude" +name = "myagents" version = "0.1.0" source = { editable = "." } dependencies = [