rename: myclaude -> myagents

This commit is contained in:
Zhengshou Lai
2026-07-01 23:27:14 +08:00
parent 1db0187b70
commit 7f1aa9f27e
23 changed files with 859 additions and 937 deletions
+9 -7
View File
@@ -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. 项目结构 ## 3. 项目结构
``` ```
myclaude/ myagents/
├── bin/ # Python 源码 ├── myagents/ # Python 源码
│ ├── cli.py # CLI entrypoint │ ├── cli.py # CLI entrypoint
│ ├── launcher.py # Agent 启动器
│ ├── entrypoints.py # myclaude / mykimi 独立入口
│ ├── commands/ # 子命令 │ ├── commands/ # 子命令
│ └── ... │ └── ...
├── templates/ # 模板目录 ├── templates/ # 模板目录
@@ -57,9 +59,9 @@ workspace/ # 主工作环境 (默认 --cwd 目标, 独立目录)
└── tmp/ # 临时文件 └── tmp/ # 临时文件
``` ```
默认运行 `myclaude`(不带 --cwd)时,cwd 为 `MYCLAUDE_WORKSPACE_ROOT` 默认运行 `myagents claude``myagents kimi`(不带 --cwd)时,cwd 为
(默认 `~/workspace`),指向独立工作区 CLAUDE.md。 `MYAGENTS_WORKSPACE_ROOT`(默认 `~/workspace`),指向独立工作区 CLAUDE.md。
开发 CLI 时运行 `myclaude --cwd .` 以读取本文件。 开发 CLI 时运行 `myagents claude --cwd .``myagents kimi --cwd .` 以读取本文件。
--- ---
+20 -13
View File
@@ -1,9 +1,10 @@
ROOT_DIR := $(shell pwd) ROOT_DIR := $(shell pwd)
VENV_MYCLAUDE := $(ROOT_DIR)/.venv/bin/myclaude VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
USER_LOCAL_MYCLAUDE := $(HOME)/.local/bin/myclaude USER_BIN_DIR := $(HOME)/.local/bin
COMMANDS := myagents myclaude mykimi
COMP_DIR := $(HOME)/.local/bin/completions 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: help:
@echo "Usage: make [target]" @echo "Usage: make [target]"
@@ -18,22 +19,28 @@ install:
test -x .venv/bin/pip || python3 -m venv .venv; \ test -x .venv/bin/pip || python3 -m venv .venv; \
PIP_USER=0 .venv/bin/pip install -e .; \ PIP_USER=0 .venv/bin/pip install -e .; \
fi fi
@$(MAKE) _symlink-myclaude @$(MAKE) _symlink-commands
@$(MAKE) _install-completions @$(MAKE) _install-completions
_symlink-myclaude: _symlink-commands:
@test -x "$(VENV_MYCLAUDE)" || { echo "error: missing $(VENV_MYCLAUDE)"; exit 1; } @mkdir -p "$(USER_BIN_DIR)"
@mkdir -p "$(HOME)/.local/bin" @for cmd in $(COMMANDS); do \
@ln -sf "$(VENV_MYCLAUDE)" "$(USER_LOCAL_MYCLAUDE)" src="$(VENV_BIN_DIR)/$$cmd"; \
@echo "Linked $(USER_LOCAL_MYCLAUDE) -> $(VENV_MYCLAUDE)" 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: _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 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: _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" @echo "Removed completions"
+76 -490
View File
@@ -1,535 +1,121 @@
# MyClaude # MyAgents
一个基于 Claude Code 的个人助理工具,支持通过飞书机器人进行远程交互 一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI 等工具
## 功能特性 ## 功能
- 🤖 多模型支持:Claude、DeepSeek、Kimi、Kimi Code - 🤖 **多 Agent 统一入口**`myagents claude` / `myagents kimi`
- 💬 飞书机器人集成:随时随地与 AI 助手对话 - 🚀 **独立快捷命令**`myclaude``mykimi`
- 📝 智能记忆:自动保存对话历史和项目上下文 - 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖
- 🔧 技能扩展:支持自定义 Skill 增强功能 - 📝 **Session 管理**`--list` 列出可恢复会话
- ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传
## 视频教程
📺 **部署流程详解**[Bilibili - MyClaude 完整部署指南](https://www.bilibili.com/video/BV15TPrzeELm/)
## 项目结构 ## 项目结构
``` ```
myclaude/ myagents/
├── .claude/ # Claude Code 配置 ├── myagents/
── skills/ # 自定义技能 ── cli.py # myagents 统一入口
├── .venv/ # Python 虚拟环境 │ ├── entrypoints.py # myclaude / mykimi 独立入口
├── templates/ # 模板目录 │ ├── launcher.py # Agent 启动逻辑
── workspace/ # 工作区模板(CLAUDE.md, .gitignore ── project_root.py # 项目/工作区根目录解析
├── bin/ # Python 源码 │ └── commands/ # update / upgrade 子命令
│ ├── cli.py # CLI 入口 ├── tests/
│ ├── commands/ # 子命令 ├── templates/ # workspace 模板
│ └── ... ├── pyproject.toml
├── CLAUDE.md # 项目配置(自动生成) ├── Makefile
── pyproject.toml # Python 项目配置 ── README.md
└── README.md # 本文件
``` ```
## 快速开始 ## 安装
### 1. 安装 Claude Code **前置依赖**
**macOS/Linux:**
```bash
# 推荐:官方安装脚本
curl -fsSL https://claude.ai/install.sh | sh
# 备选:npmHomebrew 不可用时,固定 2.1.118,新版本有 Kimi 兼容问题)
npm install -g @anthropic-ai/claude-code@2.1.118
```
> ⚠️ **npm 安装已弃用**`npm install -g @anthropic-ai/claude-code` 从 2026-01-21v2.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+ | 项目运行环境 | | Python | 3.10+ | 项目运行环境 |
| make | - | 构建工具(macOS/Linux 通常自带) | | make | - | 构建工具(macOS/Linux 通常自带) |
| git | - | 代码仓库管理 | | git | - | 代码仓库管理 |
| uv 或 pip | - | Python 包管理器,推荐 [uv](https://github.com/astral-sh/uv) | | uv 或 pip | - | Python 包管理器,推荐 [uv](https://github.com/astral-sh/uv) |
**检查依赖:** **克隆并安装**
```bash ```bash
python3 --version # 需 >= 3.10 git clone <repository-url> /path/to/myagents
make --version # 确认已安装 cd /path/to/myagents
git --version # 确认已安装
```
**克隆并安装:**
```bash
# 克隆到本地
git clone <repository-url> ~/your-path/myclaude
cd ~/your-path/myclaude
# 安装(uv 则 uv sync,否则创建 .venv 并用 pip install -e
make install 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 ```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-id> # 恢复指定 session
myagents kimi # 启动 Kimi Code CLI
myagents kimi -S <session-id> # 恢复 Kimi session
myagents update # 重装 myagents 并同步 workspace 链接
myagents upgrade # update 别名
# 独立入口(与上面完全等价)
myclaude
myclaude --cwd . -r <session-id>
mykimi
mykimi -S <session-id>
``` ```
首次运行 `myclaude` 时,会自动创建工作区目录并复制 `templates/workspace/` 中的模板文件 各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session``-c`continue)、`-y`yolo)等,直接加在命令后即可
**Shell 补全(可选):**`~/.zshrc``~/.bashrc` 中加入(有命令再注册,避免未安装时报错): ## 配置
| 环境变量 | 说明 |
|---|---|
| `MYAGENTS_PROJECT_ROOT` | 覆盖项目根目录自动检测 |
| `MYAGENTS_WORKSPACE_ROOT` | 覆盖 workspace 根目录,默认 `~/workspace` |
设置示例:
```bash ```bash
if command -v myclaude >/dev/null 2>&1; then export MYAGENTS_WORKSPACE_ROOT="/your/custom/workspace"
eval "$(_MYCLAUDE_COMPLETE=zsh_source myclaude)" ```
## Shell 补全(可选)
`~/.zshrc``~/.bashrc` 中加入:
```bash
if command -v myagents >/dev/null 2>&1; then
eval "$(_MYAGENTS_COMPLETE=zsh_source myagents)"
fi fi
``` ```
Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 配置文件。 Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 配置文件。
### 5. 安装 Metabot ## 开发
**快速安装:**
```bash ```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 自启动 ## 完整部署指南
**配置环境变量:** 如果你要部署的是**小荷助理**完整环境(含 Claude Code、cc-switch、Metabot、飞书机器人等),请参见 [xiaohe-agent 部署文档](../../README.md)。
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 Codecwd = ~/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 <url>、/skill git-proxy-clone skill <owner/repo@skill>、/skill git-proxy-clone exec "<command>"
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 # 取消代理
```
---
## License ## License
+1
View File
@@ -0,0 +1 @@
"""Myagents CLI package."""
+3
View File
@@ -0,0 +1,3 @@
from myagents.cli import main
main()
+42
View File
@@ -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()
@@ -9,5 +9,5 @@ __all__ = ["update_cmd", "upgrade_cmd"]
@click.command("upgrade") @click.command("upgrade")
def upgrade_cmd() -> None: 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) return click.get_current_context().invoke(update_cmd)
@@ -6,7 +6,7 @@ from pathlib import Path
from rich.console import Console 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() console = Console()
stderr_console = Console(stderr=True) stderr_console = Console(stderr=True)
@@ -139,18 +139,18 @@ def sync_workspace_links(
skipped = 0 skipped = 0
broken = 0 broken = 0
# Handle myclaude separately: use project root detection, not zshrc. # Handle myagents separately: use project root detection, not zshrc.
myclaude_target = get_myclaude_project_root() myagents_target = get_project_root()
if _sync_link(workspace, "myclaude", myclaude_target, dry_run, force): if _sync_link(workspace, "myagents", myagents_target, dry_run, force):
action = "would update" if dry_run else "updated" action = "would update" if dry_run else "updated"
if verbose: if verbose:
console.print( console.print(
f"[green]myclaude: {action} -> {myclaude_target}[/green]" f"[green]myagents: {action} -> {myagents_target}[/green]"
) )
changed += 1 changed += 1
else: else:
if verbose: if verbose:
console.print("[dim]myclaude: already correct[/dim]") console.print("[dim]myagents: already correct[/dim]")
for name, env_vars in _LINK_MAP.items(): for name, env_vars in _LINK_MAP.items():
target = _resolve_target(env_vars, zshrc) target = _resolve_target(env_vars, zshrc)
@@ -1,4 +1,4 @@
"""Reinstall myclaude from the local repo.""" """Reinstall myagents from the local repo."""
import subprocess import subprocess
import sys import sys
@@ -7,8 +7,8 @@ from pathlib import Path
import click import click
from rich.console import Console from rich.console import Console
from myclaude.commands.init import sync_workspace_links from myagents.commands.init import sync_workspace_links
from myclaude.project_root import get_myclaude_project_root from myagents.project_root import get_project_root
console = Console() console = Console()
stderr_console = Console(stderr=True) stderr_console = Console(stderr=True)
@@ -29,17 +29,17 @@ def _run_pip_editable(root: Path) -> int:
@click.command("update") @click.command("update")
def update_cmd() -> None: def update_cmd() -> None:
"""Reinstall myclaude and sync workspace symlinks.""" """Reinstall myagents and sync workspace symlinks."""
root = get_myclaude_project_root() root = get_project_root()
fallback = Path.home() / ".myclaude" fallback = Path.home() / ".myagents"
if root == fallback: if root == fallback:
stderr_console.print( 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.", "or run from the clone.",
) )
raise SystemExit(1) raise SystemExit(1)
console.print("[bold cyan]Updating myclaude…[/bold cyan]") console.print("[bold cyan]Updating myagents…[/bold cyan]")
makefile = root / "Makefile" makefile = root / "Makefile"
if makefile.is_file(): if makefile.is_file():
rc = _run_make_install(root) rc = _run_make_install(root)
@@ -54,7 +54,7 @@ def update_cmd() -> None:
if rc != 0: if rc != 0:
stderr_console.print("[red]Update failed.[/red]") stderr_console.print("[red]Update failed.[/red]")
raise SystemExit(rc) raise SystemExit(rc)
console.print("[green]myclaude updated.[/green]") console.print("[green]myagents updated.[/green]")
console.print() console.print()
console.print("[bold cyan]Syncing workspace links…[/bold cyan]") console.print("[bold cyan]Syncing workspace links…[/bold cyan]")
+16
View File
@@ -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()
+276
View File
@@ -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 <cwd-md5>/<session-id>/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 <id>"
return "--resume, -r <id>"
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 <backend>`` (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
@@ -1,4 +1,4 @@
"""Resolve myclaude repository and workspace roots.""" """Resolve myagents repository and workspace roots."""
import os import os
from pathlib import Path from pathlib import Path
@@ -7,19 +7,19 @@ _MAX_WALK_DEPTH = 16
_MAX_SOURCE_DEPTH = 8 _MAX_SOURCE_DEPTH = 8
def _pyproject_names_myclaude(path: Path) -> bool: def _pyproject_names_myagents(path: Path) -> bool:
try: try:
text = path.read_text(encoding="utf-8", errors="ignore") text = path.read_text(encoding="utf-8", errors="ignore")
except OSError: except OSError:
return False 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: def _walk_up_for_pyproject(start: Path) -> Path | None:
p = start.resolve() p = start.resolve()
for _ in range(_MAX_WALK_DEPTH): for _ in range(_MAX_WALK_DEPTH):
candidate = p / "pyproject.toml" candidate = p / "pyproject.toml"
if candidate.is_file() and _pyproject_names_myclaude(candidate): if candidate.is_file() and _pyproject_names_myagents(candidate):
return p return p
parent = p.parent parent = p.parent
if parent == p: if parent == p:
@@ -28,13 +28,13 @@ def _walk_up_for_pyproject(start: Path) -> Path | None:
return 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: if env_root:
return Path(env_root).expanduser().resolve() return Path(env_root).expanduser().resolve()
@@ -50,27 +50,27 @@ def get_myclaude_project_root() -> Path:
here = Path(__file__).resolve().parent here = Path(__file__).resolve().parent
for _ in range(_MAX_SOURCE_DEPTH): for _ in range(_MAX_SOURCE_DEPTH):
pyproject = here / "pyproject.toml" pyproject = here / "pyproject.toml"
if pyproject.is_file() and _pyproject_names_myclaude(pyproject): if pyproject.is_file() and _pyproject_names_myagents(pyproject):
return here return here
if here.parent == here: if here.parent == here:
break break
here = here.parent here = here.parent
return Path.home() / ".myclaude" return Path.home() / ".myagents"
def get_workspace_root() -> Path: 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. 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: if env_root:
root = Path(env_root).expanduser().resolve() root = Path(env_root).expanduser().resolve()
else: else:
project_root = get_myclaude_project_root() project_root = get_project_root()
project_workspace = project_root / "workspace" project_workspace = project_root / "workspace"
if project_workspace.is_dir(): if project_workspace.is_dir():
root = project_workspace root = project_workspace
-1
View File
@@ -1 +0,0 @@
"""Myclaude CLI package."""
-3
View File
@@ -1,3 +0,0 @@
from myclaude.cli import main
main()
-198
View File
@@ -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-cwd>/."""
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 <session-id>[/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 <id>``, ``--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 <id>, --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()
+6 -4
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "myclaude" name = "myagents"
version = "0.1.0" 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" requires-python = ">=3.10"
dependencies = [ dependencies = [
"click>=8.3.2", "click>=8.3.2",
@@ -9,7 +9,9 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
myclaude = "myclaude.cli:cli" myagents = "myagents.cli:cli"
myclaude = "myagents.entrypoints:claude_main"
mykimi = "myagents.entrypoints:kimi_main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8.0"] dev = ["pytest>=8.0"]
@@ -23,7 +25,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["myclaude"] packages = ["myagents"]
[tool.black] [tool.black]
line-length = 80 line-length = 80
+33 -15
View File
@@ -1,46 +1,64 @@
#!/bin/bash #!/bin/bash
# Install shell completions for myclaude to user directory only # Install shell completions for myagents, myclaude, mykimi to user directory only
# Usage: install_completion.sh <venv_myclaude_path> <comp_dir> # Usage: install_completion.sh <venv_bin_dir> <comp_dir>
VENV_MYCLAUDE="$1" VENV_BIN_DIR="$1"
COMP_DIR="$2" COMP_DIR="$2"
if [[ -z "$VENV_MYCLAUDE" || -z "$COMP_DIR" ]]; then if [[ -z "$VENV_BIN_DIR" || -z "$COMP_DIR" ]]; then
echo "Usage: $0 <venv_myclaude_path> <comp_dir>" >&2 echo "Usage: $0 <venv_bin_dir> <comp_dir>" >&2
exit 1 exit 1
fi fi
USER_SHELL=$(basename "$SHELL") USER_SHELL=$(basename "$SHELL")
# Portable uppercase helper (macOS bash 3.2 lacks ${var^^}).
_upcase() {
printf '%s' "$1" | tr '[:lower:]' '[:upper:]'
}
install_zsh_completion() { 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; } 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 if eval "$var=zsh_source ${VENV_BIN_DIR}/${cmd}" > "$out" 2>/dev/null; then
echo "Installed zsh completion: $COMP_DIR/_myclaude" echo "Installed zsh completion: $out"
return 0 return 0
else else
echo "Warning: failed to generate zsh completion" >&2 echo "Warning: failed to generate zsh completion for $cmd" >&2
return 1 return 1
fi fi
} }
install_bash_completion() { 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; } 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 if eval "$var=bash_source ${VENV_BIN_DIR}/${cmd}" > "$out" 2>/dev/null; then
echo "Installed bash completion: $COMP_DIR/myclaude.bash" echo "Installed bash completion: $out"
return 0 return 0
else else
echo "Warning: failed to generate bash completion" >&2 echo "Warning: failed to generate bash completion for $cmd" >&2
return 1 return 1
fi fi
} }
if [[ "$USER_SHELL" == "zsh" ]]; then 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 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 else
echo "Shell '$USER_SHELL' is not supported for automatic completion installation." echo "Shell '$USER_SHELL' is not supported for automatic completion installation."
echo "To install completions manually, run one of the following commands:" echo "To install completions manually, run one of the following commands:"
echo " zsh: _MYCLAUDE_COMPLETE=zsh_source $VENV_MYCLAUDE > /path/to/completions/_myclaude" for cmd in myagents myclaude mykimi; do
echo " bash: _MYCLAUDE_COMPLETE=bash_source $VENV_MYCLAUDE > /path/to/completions/myclaude.bash" 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 fi
+52
View File
@@ -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())
-37
View File
@@ -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())
+195 -114
View File
@@ -1,23 +1,25 @@
"""Tests for myclaude.cli.""" """Tests for myagents CLI."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from click.testing import CliRunner from click.testing import CliRunner
from myclaude.cli import cli from myagents.cli import cli
class TestCliHelp: class TestMyagentsHelp:
"""Tests for CLI help and basic invocation.""" """Tests for top-level myagents command."""
def test_help_shows_options(self) -> None: def test_help_shows_agent_subcommands(self) -> None:
"""--help should show cwd option only.""" """--help should list claude and kimi subcommands."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--help"]) result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "--cwd" in result.output assert "claude" in result.output
assert "--dangerously-skip-permissions" not 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: def test_version_shows_version(self) -> None:
"""--version should show package version.""" """--version should show package version."""
@@ -26,8 +28,192 @@ class TestCliHelp:
assert result.exit_code == 0 assert result.exit_code == 0
assert "version" in result.output.lower() 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: def test_update_subcommand_exists(self) -> None:
"""update subcommand should be registered."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["update", "--help"]) result = runner.invoke(cli, ["update", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -37,111 +223,6 @@ class TestCliHelp:
) )
def test_upgrade_alias_exists(self) -> None: def test_upgrade_alias_exists(self) -> None:
"""upgrade should be an alias for update."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["upgrade", "--help"]) result = runner.invoke(cli, ["upgrade", "--help"])
assert result.exit_code == 0 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 <id>` 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()
+75
View File
@@ -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]
+24 -24
View File
@@ -1,65 +1,65 @@
"""Tests for myclaude.project_root.""" """Tests for myagents.project_root."""
import os import os
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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: class TestGetProjectRoot:
"""Tests for get_myclaude_project_root.""" """Tests for get_project_root."""
def test_env_var_takes_priority(self, tmp_path: Path) -> None: 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 = tmp_path / "fake_repo"
fake_root.mkdir() 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)}): with patch.dict(os.environ, {"MYAGENTS_PROJECT_ROOT": str(fake_root)}):
result = get_myclaude_project_root() result = get_project_root()
assert result == fake_root.resolve() assert result == fake_root.resolve()
def test_env_var_expands_tilde(self, tmp_path: Path) -> None: 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 = tmp_path / "home"
home.mkdir() home.mkdir()
fake_root = home / "fake_repo" fake_root = home / "fake_repo"
fake_root.mkdir() fake_root.mkdir()
(fake_root / "pyproject.toml").write_text('name = "myclaude"\n') (fake_root / "pyproject.toml").write_text('name = "myagents"\n')
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"MYCLAUDE_PROJECT_ROOT": "~/fake_repo", "MYAGENTS_PROJECT_ROOT": "~/fake_repo",
"HOME": str(home), "HOME": str(home),
}, },
): ):
result = get_myclaude_project_root() result = get_project_root()
assert result == fake_root.resolve() assert result == fake_root.resolve()
def test_fallback_when_not_in_repo(self) -> None: 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 ( with (
patch.dict(os.environ, {}, clear=True), patch.dict(os.environ, {}, clear=True),
patch("pathlib.Path.cwd", side_effect=OSError), patch("pathlib.Path.cwd", side_effect=OSError),
patch( patch(
"myclaude.project_root._pyproject_names_myclaude", "myagents.project_root._pyproject_names_myagents",
return_value=False, return_value=False,
), ),
): ):
result = get_myclaude_project_root() result = get_project_root()
assert result == Path.home() / ".myclaude" assert result == Path.home() / ".myagents"
class TestGetWorkspaceRoot: class TestGetWorkspaceRoot:
"""Tests for get_workspace_root.""" """Tests for get_workspace_root."""
def test_env_var_takes_priority(self, tmp_path: Path) -> None: 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" 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() result = get_workspace_root()
assert result == custom.resolve() assert result == custom.resolve()
assert result.is_dir() assert result.is_dir()
@@ -69,21 +69,21 @@ class TestGetWorkspaceRoot:
new_ws = tmp_path / "new_workspace" new_ws = tmp_path / "new_workspace"
assert not new_ws.exists() 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() result = get_workspace_root()
assert result == new_ws.resolve() assert result == new_ws.resolve()
assert result.is_dir() assert result.is_dir()
def test_defaults_to_project_workspace(self, tmp_path: Path) -> None: def test_defaults_to_project_workspace(self, tmp_path: Path) -> None:
"""When project_root/workspace exists, use it.""" """When project_root/workspace exists, use it."""
project_root = tmp_path / "myclaude" project_root = tmp_path / "myagents"
project_root.mkdir() project_root.mkdir()
workspace = project_root / "workspace" workspace = project_root / "workspace"
workspace.mkdir() workspace.mkdir()
(project_root / "pyproject.toml").write_text('name = "myclaude"\n') (project_root / "pyproject.toml").write_text('name = "myagents"\n')
with patch.dict( 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() result = get_workspace_root()
assert result == workspace.resolve() assert result == workspace.resolve()
@@ -95,8 +95,8 @@ class TestGetWorkspaceRoot:
with ( with (
patch.dict(os.environ, {}, clear=True), patch.dict(os.environ, {}, clear=True),
patch( patch(
"myclaude.project_root.get_myclaude_project_root", "myagents.project_root.get_project_root",
return_value=tmp_path / ".myclaude", return_value=tmp_path / ".myagents",
), ),
patch("pathlib.Path.home", return_value=home), patch("pathlib.Path.home", return_value=home),
): ):
Generated
+1 -1
View File
@@ -66,7 +66,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "myclaude" name = "myagents"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [