rename: myclaude -> myagents
This commit is contained in:
@@ -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 .` 以读取本文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
# 备选: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+ | 项目运行环境 |
|
| 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 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 <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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Myagents CLI package."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from myagents.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -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]")
|
||||||
@@ -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()
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""Myclaude CLI package."""
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from myclaude.cli import main
|
|
||||||
|
|
||||||
main()
|
|
||||||
-198
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
@@ -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()
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user