feat: initial implementation of gitee-cli

- auth: login/logout/status with Personal Access Token
- repo: list/create/view/clone/delete
- issue: list/create/view/close/comment
- pr: list/create/view/merge/close
- release: list/create
- org: list
- api: raw API call with --field/--query/--paginate

Features:
- TypeScript + ESM
- commander.js CLI parsing
- Node built-in fetch (no axios)
- Auto-detect owner/repo from git remote (gitee.com)
- GITEE_TOKEN env var priority over config file
- --json flag for machine-readable output
- --page/--per-page pagination support
- Friendly error messages (401/403/404)
- Config stored at ~/.config/gitee-cli/config.json
This commit is contained in:
小橘 2026-04-01 13:14:17 +00:00
commit d44e6d049c
16 changed files with 2139 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
*.js.map

4
.npmignore Normal file
View File

@ -0,0 +1,4 @@
src/
*.ts
tsconfig.json
.gitignore

177
README.md Normal file
View File

@ -0,0 +1,177 @@
# gitee-cli
**Gitee (码云) 命令行工具** — 类似 `gh`,但面向 Gitee。
A command-line tool for Gitee (码云) — like `gh`, but for Gitee.
[![npm version](https://img.shields.io/npm/v/gitee-cli.svg)](https://www.npmjs.com/package/gitee-cli)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
---
## 安装 / Installation
### 全局安装 (Recommended)
```bash
npm install -g gitee-cli
```
### 从源码构建 / Build from source
```bash
git clone https://github.com/shazhou-ww/gitee-cli.git
cd gitee-cli
npm install
npm run build
npm link
```
---
## 认证 / Authentication
gitee-cli 使用 Gitee Personal Access Token 认证。
1. 前往 https://gitee.com/profile/personal_access_tokens 创建 Token
2. 运行登录命令
```bash
gitee auth login
```
### 环境变量 / Environment Variable
`GITEE_TOKEN` 优先级高于配置文件:
```bash
export GITEE_TOKEN=your_token_here
```
### 命令 / Commands
```bash
gitee auth login # 交互式登录
gitee auth logout # 清除认证
gitee auth status # 查看当前认证状态
```
---
## 命令列表 / Commands
### 仓库 / Repository
```bash
gitee repo list # 列出我的仓库
gitee repo list --owner <user> # 列出指定用户的仓库
gitee repo list --type private # 只列出私有仓库
gitee repo create <name> # 创建仓库
gitee repo create <name> --private # 创建私有仓库
gitee repo create <name> --description "..."
gitee repo view # 查看当前仓库 (自动检测)
gitee repo view <owner/repo> # 查看指定仓库
gitee repo clone <owner/repo> # Clone 仓库
gitee repo delete <owner/repo> # 删除仓库 (需确认)
```
### Issue
```bash
gitee issue list # 列出 issues (当前仓库)
gitee issue list --repo <owner/repo> # 指定仓库
gitee issue list --state closed # 列出已关闭的 issues
gitee issue create --title "Bug fix" # 创建 issue
gitee issue create --title "..." --body "..." --repo <owner/repo>
gitee issue view <number> # 查看 issue 详情
gitee issue close <number> # 关闭 issue
gitee issue comment <number> --body "..." # 评论
```
### Pull Request
```bash
gitee pr list # 列出 PRs (当前仓库)
gitee pr list --state merged # 已合并的 PRs
gitee pr create --title "feat: xxx" --head feature-branch
gitee pr create --title "..." --head <branch> --base master --body "..."
gitee pr view <number> # 查看 PR 详情
gitee pr merge <number> # 合并 PR
gitee pr merge <number> --method squash # Squash 合并
gitee pr close <number> # 关闭 PR
```
### Release
```bash
gitee release list # 列出 releases
gitee release list --repo <owner/repo>
gitee release create --tag v1.0.0 --name "v1.0.0 Release"
gitee release create --tag v1.0.0 --name "..." --body "Release notes"
```
### 组织 / Organization
```bash
gitee org list # 列出我加入的组织
```
### 通用 API / Raw API
```bash
gitee api GET /v5/emojis # 不需要认证
gitee api GET /v5/user # 获取当前用户
gitee api GET /v5/repos/owner/repo
gitee api POST /v5/user/repos --field name=myrepo --field private=true
gitee api GET /v5/repos/owner/repo/issues --query state=open
gitee api GET /v5/repos/owner/repo/issues --paginate # 自动翻页
```
---
## 通用选项 / Global Options
| 选项 | 说明 |
|------|------|
| `--json` | 输出原始 JSON(方便脚本/AI 解析) |
| `--repo <owner/repo>` | 指定仓库(省略时自动检测 git remote) |
| `--page <n>` | 分页页码(默认 1) |
| `--per-page <n>` | 每页条数(默认 20,最大 100) |
---
## 自动检测仓库 / Auto-detect Repository
当你在一个 Gitee 仓库目录内运行命令时,`--repo` 参数可以省略,gitee-cli 会自动从 `git remote` 检测 `owner/repo`
支持两种 remote 格式:
- `https://gitee.com/owner/repo.git`
- `git@gitee.com:owner/repo.git`
---
## 配置文件 / Config File
Token 存储在 `~/.config/gitee-cli/config.json`
```json
{
"token": "your_token",
"username": "your_username"
}
```
---
## 技术栈 / Tech Stack
- Node.js 18+ (ESM)
- TypeScript
- commander.js
- Node 内置 `fetch` (无 axios 依赖)
---
## License
MIT

609
package-lock.json generated Normal file
View File

@ -0,0 +1,609 @@
{
"name": "gitee-cli",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitee-cli",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"commander": "^12.1.0"
},
"bin": {
"gitee": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^20.14.0",
"tsx": "^4.15.7",
"typescript": "^5.5.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "gitee-cli",
"version": "0.1.0",
"description": "Gitee (码云) command-line tool — like gh, but for Gitee",
"type": "module",
"bin": {
"gitee": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts",
"prepublishOnly": "npm run build"
},
"keywords": ["gitee", "cli", "git", "vcs"],
"author": "小橘",
"license": "MIT",
"dependencies": {
"commander": "^12.1.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"tsx": "^4.15.7",
"typescript": "^5.5.2"
},
"engines": {
"node": ">=18.0.0"
}
}

141
src/api.ts Normal file
View File

@ -0,0 +1,141 @@
import { execSync } from 'child_process';
const API_BASE = 'https://gitee.com/api/v5';
export interface RequestOptions {
method?: string;
params?: Record<string, string | number | boolean | undefined>;
body?: Record<string, unknown>;
token?: string;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiRequest<T = unknown>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { method = 'GET', params = {}, body, token } = options;
// Build URL
const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`);
// Add token to params if available
if (token) {
url.searchParams.set('access_token', token);
}
// Add other query params
for (const [key, val] of Object.entries(params)) {
if (val !== undefined && val !== null) {
url.searchParams.set(key, String(val));
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'gitee-cli/0.1.0',
};
const fetchOptions: RequestInit = {
method,
headers,
};
if (body && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(url.toString(), fetchOptions);
if (!response.ok) {
let message = `HTTP ${response.status} ${response.statusText}`;
try {
const errBody = await response.json() as Record<string, unknown>;
if (errBody.message) {
message = String(errBody.message);
} else if (errBody.error) {
message = String(errBody.error);
}
} catch {
// ignore
}
throw new ApiError(response.status, message);
}
// 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
/**
* Detect owner/repo from current git remote (gitee.com)
*/
export function detectRepo(): string | undefined {
try {
const remote = execSync('git remote get-url origin 2>/dev/null', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
// Match: https://gitee.com/owner/repo or git@gitee.com:owner/repo.git
const httpsMatch = remote.match(/https:\/\/gitee\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
const sshMatch = remote.match(/git@gitee\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
return (httpsMatch || sshMatch)?.[1];
} catch {
return undefined;
}
}
/**
* Resolve owner/repo: explicit arg > auto-detect > error
*/
export function resolveRepo(explicit?: string): string {
if (explicit) return explicit;
const detected = detectRepo();
if (detected) {
return detected;
}
console.error('Error: Could not determine repository. Use --repo <owner/repo> or run inside a gitee.com git directory.');
process.exit(1);
}
export function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
export function handleError(err: unknown): never {
if (err instanceof ApiError) {
if (err.status === 401) {
console.error('Error: Unauthorized. Please run `gitee auth login` first or set GITEE_TOKEN.');
} else if (err.status === 404) {
console.error('Error: Not found. Check repository name and your access permissions.');
} else if (err.status === 403) {
console.error('Error: Forbidden. You may not have permission for this operation.');
} else {
console.error(`Error: ${err.message}`);
}
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {
console.error('Unknown error occurred');
}
process.exit(1);
}

26
src/cli.ts Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { registerAuthCommands } from './commands/auth.js';
import { registerRepoCommands } from './commands/repo.js';
import { registerIssueCommands } from './commands/issue.js';
import { registerPrCommands } from './commands/pr.js';
import { registerReleaseCommands } from './commands/release.js';
import { registerOrgCommands } from './commands/org.js';
import { registerApiCommand } from './commands/api.js';
const program = new Command();
program
.name('gitee')
.description('Gitee (码云) command-line tool — like gh, but for Gitee')
.version('0.1.0');
registerAuthCommands(program);
registerRepoCommands(program);
registerIssueCommands(program);
registerPrCommands(program);
registerReleaseCommands(program);
registerOrgCommands(program);
registerApiCommand(program);
program.parse(process.argv);

99
src/commands/api.ts Normal file
View File

@ -0,0 +1,99 @@
import { Command } from 'commander';
import { getToken } from '../config.js';
import { apiRequest, handleError } from '../api.js';
export function registerApiCommand(program: Command): void {
program
.command('api <method> <path>')
.description('Make a raw API call to Gitee API v5')
.option('--field <key=value>', 'Set request field (repeatable)', (val: string, prev: string[]) => {
prev.push(val);
return prev;
}, [] as string[])
.option('--query <key=value>', 'Set query parameter (repeatable)', (val: string, prev: string[]) => {
prev.push(val);
return prev;
}, [] as string[])
.option('--no-auth', 'Skip authentication token')
.option('--paginate', 'Fetch all pages and combine results')
.action(async (method: string, path: string, opts: {
field: string[];
query: string[];
auth: boolean;
paginate?: boolean;
}) => {
const token = opts.auth !== false ? getToken() : undefined;
// Parse fields into body
const body: Record<string, string> = {};
for (const f of opts.field) {
const idx = f.indexOf('=');
if (idx === -1) {
console.error(`Error: Invalid field format "${f}". Use key=value.`);
process.exit(1);
}
body[f.slice(0, idx)] = f.slice(idx + 1);
}
// Parse query params
const params: Record<string, string> = {};
for (const q of opts.query) {
const idx = q.indexOf('=');
if (idx === -1) {
console.error(`Error: Invalid query format "${q}". Use key=value.`);
process.exit(1);
}
params[q.slice(0, idx)] = q.slice(idx + 1);
}
// Normalize path
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
try {
const requestOpts = {
method: method.toUpperCase(),
token,
params: Object.keys(params).length > 0 ? params : undefined,
body: ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) && Object.keys(body).length > 0 ? body : undefined,
};
// Resolve the API path:
// - If user passes /v5/... → use as-is (strip leading /v5 since API_BASE already has /v5)
// - If user passes /repos/... or /user/... → prepend nothing (API_BASE handles it)
// - Raw absolute URL → pass directly
let apiPath: string;
if (normalizedPath.startsWith('/v5/')) {
// strip /v5 prefix since apiRequest prepends API_BASE which already ends with /v5
apiPath = normalizedPath.slice(3); // remove /v5
} else {
apiPath = normalizedPath;
}
if (opts.paginate) {
const allResults: unknown[] = [];
let page = 1;
while (true) {
const pageParams = { ...params, page, per_page: 100 };
const result = await apiRequest<unknown>(apiPath, {
...requestOpts,
params: pageParams,
});
if (Array.isArray(result)) {
allResults.push(...result);
if (result.length < 100) break;
page++;
} else {
console.log(JSON.stringify(result, null, 2));
return;
}
}
console.log(JSON.stringify(allResults, null, 2));
} else {
const result = await apiRequest<unknown>(apiPath, requestOpts);
console.log(JSON.stringify(result, null, 2));
}
} catch (err) {
handleError(err);
}
});
}

141
src/commands/auth.ts Normal file
View File

@ -0,0 +1,141 @@
import { Command } from 'commander';
import * as readline from 'readline';
import { getConfig, saveConfig, clearConfig, getToken } from '../config.js';
import { apiRequest, handleError, ApiError } from '../api.js';
interface GiteeUser {
login: string;
name: string;
email?: string;
avatar_url?: string;
bio?: string;
public_repos?: number;
followers?: number;
following?: number;
}
function prompt(question: string, hidden = false): Promise<string> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
if (hidden) {
// Hide input for token
process.stdout.write(question);
let value = '';
const stdin = process.stdin;
stdin.setRawMode?.(true);
stdin.resume();
stdin.setEncoding('utf-8');
stdin.on('data', (char: string) => {
if (char === '\n' || char === '\r' || char === '\u0003') {
stdin.setRawMode?.(false);
stdin.pause();
process.stdout.write('\n');
rl.close();
resolve(value);
} else if (char === '\u007F') {
if (value.length > 0) {
value = value.slice(0, -1);
process.stdout.write('\b \b');
}
} else {
value += char;
process.stdout.write('*');
}
});
} else {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
}
});
}
export function registerAuthCommands(program: Command): void {
const auth = program
.command('auth')
.description('Manage authentication');
auth
.command('login')
.description('Authenticate with a Gitee Personal Access Token')
.option('--with-token', 'Read token from stdin (non-interactive)')
.action(async (opts: { withToken?: boolean }) => {
let token: string;
if (opts.withToken) {
// Read from stdin
const chunks: string[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
token = chunks.join('').trim();
} else {
console.log('Gitee CLI Authentication');
console.log('Get your token at: https://gitee.com/profile/personal_access_tokens');
console.log('');
token = await prompt('Enter your Gitee Personal Access Token: ', true);
}
if (!token) {
console.error('Error: Token cannot be empty.');
process.exit(1);
}
// Verify the token
console.log('Verifying token...');
try {
const user = await apiRequest<GiteeUser>('/user', { token });
saveConfig({ token, username: user.login });
console.log(`✓ Logged in as ${user.login} (${user.name || user.login})`);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
console.error('Error: Invalid token. Please check your Personal Access Token.');
process.exit(1);
}
handleError(err);
}
});
auth
.command('logout')
.description('Clear stored authentication credentials')
.action(() => {
clearConfig();
console.log('✓ Logged out. Token cleared.');
});
auth
.command('status')
.description('Show current authentication status')
.action(async () => {
const token = getToken();
if (!token) {
console.log('Not logged in.');
console.log('Run `gitee auth login` to authenticate.');
return;
}
const fromEnv = !!process.env.GITEE_TOKEN;
const config = getConfig();
console.log(`Token source: ${fromEnv ? 'GITEE_TOKEN (env)' : 'config file'}`);
if (config.username) {
console.log(`Cached username: ${config.username}`);
}
console.log('Verifying token...');
try {
const user = await apiRequest<GiteeUser>('/user', { token });
console.log(`✓ Authenticated as ${user.login} (${user.name || user.login})`);
if (user.email) console.log(`Email: ${user.email}`);
if (user.public_repos !== undefined) console.log(`Public repos: ${user.public_repos}`);
} catch {
console.log('✗ Token is invalid or expired.');
}
});
}

215
src/commands/issue.ts Normal file
View File

@ -0,0 +1,215 @@
import { Command } from 'commander';
import { getToken } from '../config.js';
import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js';
interface GiteeIssue {
id: number;
number: string;
title: string;
state: string;
body?: string;
user?: { login: string };
assignee?: { login: string };
labels?: Array<{ name: string; color: string }>;
comments?: number;
created_at: string;
updated_at: string;
html_url: string;
}
export function registerIssueCommands(program: Command): void {
const issue = program
.command('issue')
.description('Manage issues');
issue
.command('list')
.description('List issues in a repository')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--state <state>', 'State: open|closed|all (default: open)', 'open')
.option('--page <n>', 'Page number', '1')
.option('--per-page <n>', 'Results per page', '20')
.option('--json', 'Output raw JSON')
.action(async (opts: { repo?: string; state?: string; page?: string; perPage?: string; json?: boolean }) => {
const token = getToken();
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const issues = await apiRequest<GiteeIssue[]>(`/repos/${owner}/${repo}/issues`, {
token,
params: {
state: opts.state || 'open',
page: opts.page || 1,
per_page: opts.perPage || 20,
},
});
if (opts.json) {
console.log(JSON.stringify(issues, null, 2));
return;
}
if (!issues.length) {
console.log('No issues found.');
return;
}
console.log(`Issues in ${repoName}:\n`);
for (const issue of issues) {
const labels = issue.labels?.map(l => `[${l.name}]`).join(' ') || '';
const comments = issue.comments ? `💬 ${issue.comments}` : '';
console.log(` #${issue.number} ${issue.title} ${labels}`);
console.log(` ${issue.state} · by ${issue.user?.login || 'unknown'} · ${formatDate(issue.created_at)} ${comments}`);
}
} catch (err) {
handleError(err);
}
});
issue
.command('create')
.description('Create an issue')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.requiredOption('--title <title>', 'Issue title')
.option('--body <body>', 'Issue body/description')
.option('--assignee <username>', 'Assign to user')
.option('--json', 'Output raw JSON')
.action(async (opts: { repo?: string; title: string; body?: string; assignee?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const created = await apiRequest<GiteeIssue>(`/repos/${owner}/${repo}/issues`, {
method: 'POST',
token,
body: {
title: opts.title,
body: opts.body || '',
assignee: opts.assignee,
},
});
if (opts.json) {
console.log(JSON.stringify(created, null, 2));
return;
}
console.log(`✓ Created issue #${created.number}: ${created.title}`);
console.log(` URL: ${created.html_url}`);
} catch (err) {
handleError(err);
}
});
issue
.command('view <number>')
.description('View issue details')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--json', 'Output raw JSON')
.action(async (number: string, opts: { repo?: string; json?: boolean }) => {
const token = getToken();
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const iss = await apiRequest<GiteeIssue>(`/repos/${owner}/${repo}/issues/${number}`, { token });
if (opts.json) {
console.log(JSON.stringify(iss, null, 2));
return;
}
console.log(`#${iss.number} ${iss.title}`);
console.log(`─────────────────────────────`);
console.log(`State: ${iss.state}`);
console.log(`Author: ${iss.user?.login || 'unknown'}`);
if (iss.assignee) console.log(`Assignee: ${iss.assignee.login}`);
if (iss.labels?.length) console.log(`Labels: ${iss.labels.map(l => l.name).join(', ')}`);
console.log(`Created: ${formatDate(iss.created_at)}`);
console.log(`Updated: ${formatDate(iss.updated_at)}`);
console.log(`URL: ${iss.html_url}`);
if (iss.body) {
console.log(`\n${iss.body}`);
}
} catch (err) {
handleError(err);
}
});
issue
.command('close <number>')
.description('Close an issue')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--json', 'Output raw JSON')
.action(async (number: string, opts: { repo?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const updated = await apiRequest<GiteeIssue>(`/repos/${owner}/${repo}/issues/${number}`, {
method: 'PATCH',
token,
body: { state: 'closed', repo },
});
if (opts.json) {
console.log(JSON.stringify(updated, null, 2));
return;
}
console.log(`✓ Closed issue #${number}`);
} catch (err) {
handleError(err);
}
});
issue
.command('comment <number>')
.description('Add a comment to an issue')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.requiredOption('--body <comment>', 'Comment body')
.option('--json', 'Output raw JSON')
.action(async (number: string, opts: { repo?: string; body: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const comment = await apiRequest<{ id: number; body: string; created_at: string }>(
`/repos/${owner}/${repo}/issues/${number}/comments`,
{
method: 'POST',
token,
body: { body: opts.body },
}
);
if (opts.json) {
console.log(JSON.stringify(comment, null, 2));
return;
}
console.log(`✓ Comment added (id: ${comment.id})`);
} catch (err) {
handleError(err);
}
});
}

62
src/commands/org.ts Normal file
View File

@ -0,0 +1,62 @@
import { Command } from 'commander';
import { getToken } from '../config.js';
import { apiRequest, handleError } from '../api.js';
interface GiteeOrg {
id: number;
login: string;
name?: string;
avatar_url?: string;
description?: string;
html_url?: string;
public_repos?: number;
members_count?: number;
}
export function registerOrgCommands(program: Command): void {
const org = program
.command('org')
.description('Manage organizations');
org
.command('list')
.description('List organizations for the authenticated user')
.option('--page <n>', 'Page number', '1')
.option('--per-page <n>', 'Results per page', '20')
.option('--json', 'Output raw JSON')
.action(async (opts: { page?: string; perPage?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
try {
const orgs = await apiRequest<GiteeOrg[]>('/user/orgs', {
token,
params: {
page: opts.page || 1,
per_page: opts.perPage || 20,
},
});
if (opts.json) {
console.log(JSON.stringify(orgs, null, 2));
return;
}
if (!orgs.length) {
console.log('No organizations found.');
return;
}
console.log(`Organizations:\n`);
for (const o of orgs) {
console.log(` ${o.login}${o.name && o.name !== o.login ? ` (${o.name})` : ''}`);
if (o.description) console.log(` ${o.description}`);
}
} catch (err) {
handleError(err);
}
});
}

233
src/commands/pr.ts Normal file
View File

@ -0,0 +1,233 @@
import { Command } from 'commander';
import { getToken } from '../config.js';
import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js';
interface GiteePR {
id: number;
number: number;
title: string;
state: string;
body?: string;
user?: { login: string };
head?: { label: string; sha: string };
base?: { label: string; sha: string };
merged?: boolean;
merged_at?: string;
created_at: string;
updated_at: string;
html_url: string;
comments?: number;
commits?: number;
changed_files?: number;
}
export function registerPrCommands(program: Command): void {
const pr = program
.command('pr')
.description('Manage pull requests');
pr
.command('list')
.description('List pull requests')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--state <state>', 'State: open|closed|merged|all (default: open)', 'open')
.option('--page <n>', 'Page number', '1')
.option('--per-page <n>', 'Results per page', '20')
.option('--json', 'Output raw JSON')
.action(async (opts: { repo?: string; state?: string; page?: string; perPage?: string; json?: boolean }) => {
const token = getToken();
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const prs = await apiRequest<GiteePR[]>(`/repos/${owner}/${repo}/pulls`, {
token,
params: {
state: opts.state || 'open',
page: opts.page || 1,
per_page: opts.perPage || 20,
},
});
if (opts.json) {
console.log(JSON.stringify(prs, null, 2));
return;
}
if (!prs.length) {
console.log('No pull requests found.');
return;
}
console.log(`Pull requests in ${repoName}:\n`);
for (const p of prs) {
const head = p.head?.label || 'unknown';
const base = p.base?.label || 'unknown';
console.log(` #${p.number} ${p.title}`);
console.log(` ${p.state} · ${head}${base} · by ${p.user?.login || 'unknown'} · ${formatDate(p.created_at)}`);
}
} catch (err) {
handleError(err);
}
});
pr
.command('create')
.description('Create a pull request')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.requiredOption('--title <title>', 'PR title')
.requiredOption('--head <branch>', 'Source branch (head)')
.option('--base <branch>', 'Target branch (base)', 'master')
.option('--body <desc>', 'PR description')
.option('--json', 'Output raw JSON')
.action(async (opts: { repo?: string; title: string; head: string; base?: string; body?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const created = await apiRequest<GiteePR>(`/repos/${owner}/${repo}/pulls`, {
method: 'POST',
token,
body: {
title: opts.title,
head: opts.head,
base: opts.base || 'master',
body: opts.body || '',
},
});
if (opts.json) {
console.log(JSON.stringify(created, null, 2));
return;
}
console.log(`✓ Created PR #${created.number}: ${created.title}`);
console.log(` URL: ${created.html_url}`);
} catch (err) {
handleError(err);
}
});
pr
.command('view <number>')
.description('View pull request details')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--json', 'Output raw JSON')
.action(async (number: string, opts: { repo?: string; json?: boolean }) => {
const token = getToken();
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const p = await apiRequest<GiteePR>(`/repos/${owner}/${repo}/pulls/${number}`, { token });
if (opts.json) {
console.log(JSON.stringify(p, null, 2));
return;
}
console.log(`#${p.number} ${p.title}`);
console.log(`─────────────────────────────`);
console.log(`State: ${p.state}${p.merged ? ' (merged)' : ''}`);
console.log(`Author: ${p.user?.login || 'unknown'}`);
console.log(`Head: ${p.head?.label || 'unknown'}`);
console.log(`Base: ${p.base?.label || 'unknown'}`);
if (p.commits !== undefined) console.log(`Commits: ${p.commits}`);
if (p.changed_files !== undefined) console.log(`Changed: ${p.changed_files} files`);
console.log(`Created: ${formatDate(p.created_at)}`);
console.log(`Updated: ${formatDate(p.updated_at)}`);
if (p.merged_at) console.log(`Merged: ${formatDate(p.merged_at)}`);
console.log(`URL: ${p.html_url}`);
if (p.body) {
console.log(`\n${p.body}`);
}
} catch (err) {
handleError(err);
}
});
pr
.command('merge <number>')
.description('Merge a pull request')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--method <method>', 'Merge method: merge|squash|rebase', 'merge')
.option('--message <msg>', 'Merge commit message')
.option('--json', 'Output raw JSON')
.action(async (number: string, opts: { repo?: string; method?: string; message?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
const methodMap: Record<string, string> = {
merge: 'merge',
squash: 'squash',
rebase: 'rebase',
};
const mergeMethod = methodMap[opts.method || 'merge'] || 'merge';
try {
await apiRequest(`/repos/${owner}/${repo}/pulls/${number}/merge`, {
method: 'PUT',
token,
body: {
merge_method: mergeMethod,
commit_message: opts.message || '',
},
});
if (opts.json) {
console.log(JSON.stringify({ merged: true, number, method: mergeMethod }));
return;
}
console.log(`✓ Merged PR #${number} (${mergeMethod})`);
} catch (err) {
handleError(err);
}
});
pr
.command('close <number>')
.description('Close a pull request')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--json', 'Output raw JSON')
.action(async (number: string, opts: { repo?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const updated = await apiRequest<GiteePR>(`/repos/${owner}/${repo}/pulls/${number}`, {
method: 'PATCH',
token,
body: { state: 'closed' },
});
if (opts.json) {
console.log(JSON.stringify(updated, null, 2));
return;
}
console.log(`✓ Closed PR #${number}`);
} catch (err) {
handleError(err);
}
});
}

121
src/commands/release.ts Normal file
View File

@ -0,0 +1,121 @@
import { Command } from 'commander';
import { getToken } from '../config.js';
import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js';
interface GiteeRelease {
id: number;
tag_name: string;
name: string;
body?: string;
draft: boolean;
prerelease: boolean;
author?: { login: string };
created_at: string;
assets?: Array<{ id: number; name: string; size: number; download_count: number }>;
}
export function registerReleaseCommands(program: Command): void {
const release = program
.command('release')
.description('Manage releases');
release
.command('list')
.description('List releases')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--page <n>', 'Page number', '1')
.option('--per-page <n>', 'Results per page', '20')
.option('--json', 'Output raw JSON')
.action(async (opts: { repo?: string; page?: string; perPage?: string; json?: boolean }) => {
const token = getToken();
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const releases = await apiRequest<GiteeRelease[]>(`/repos/${owner}/${repo}/releases`, {
token,
params: {
page: opts.page || 1,
per_page: opts.perPage || 20,
},
});
if (opts.json) {
console.log(JSON.stringify(releases, null, 2));
return;
}
if (!releases.length) {
console.log('No releases found.');
return;
}
console.log(`Releases in ${repoName}:\n`);
for (const r of releases) {
const flags: string[] = [];
if (r.draft) flags.push('draft');
if (r.prerelease) flags.push('pre-release');
const flagStr = flags.length ? ` [${flags.join(', ')}]` : '';
console.log(` ${r.tag_name}${r.name}${flagStr}`);
console.log(` by ${r.author?.login || 'unknown'} · ${formatDate(r.created_at)}`);
if (r.assets?.length) {
console.log(` Assets: ${r.assets.map(a => a.name).join(', ')}`);
}
}
} catch (err) {
handleError(err);
}
});
release
.command('create')
.description('Create a release')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.requiredOption('--tag <tag>', 'Tag name (e.g. v1.0.0)')
.requiredOption('--name <name>', 'Release name/title')
.option('--body <desc>', 'Release description')
.option('--draft', 'Create as draft')
.option('--prerelease', 'Mark as pre-release')
.option('--json', 'Output raw JSON')
.action(async (opts: {
repo?: string;
tag: string;
name: string;
body?: string;
draft?: boolean;
prerelease?: boolean;
json?: boolean;
}) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const repoName = resolveRepo(opts.repo);
const [owner, repo] = repoName.split('/');
try {
const created = await apiRequest<GiteeRelease>(`/repos/${owner}/${repo}/releases`, {
method: 'POST',
token,
body: {
tag_name: opts.tag,
name: opts.name,
body: opts.body || '',
draft: opts.draft || false,
prerelease: opts.prerelease || false,
},
});
if (opts.json) {
console.log(JSON.stringify(created, null, 2));
return;
}
console.log(`✓ Created release: ${created.name} (${created.tag_name})`);
} catch (err) {
handleError(err);
}
});
}

224
src/commands/repo.ts Normal file
View File

@ -0,0 +1,224 @@
import { Command } from 'commander';
import { execSync } from 'child_process';
import * as readline from 'readline';
import { getToken } from '../config.js';
import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js';
interface GiteeRepo {
id: number;
full_name: string;
name: string;
description?: string;
private: boolean;
fork: boolean;
html_url: string;
ssh_url?: string;
clone_url?: string;
homepage?: string;
language?: string;
forks_count?: number;
stargazers_count?: number;
watchers_count?: number;
open_issues_count?: number;
default_branch?: string;
created_at: string;
updated_at: string;
pushed_at?: string;
owner?: { login: string };
}
function promptConfirm(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
});
});
}
export function registerRepoCommands(program: Command): void {
const repo = program
.command('repo')
.description('Manage repositories');
repo
.command('list')
.description('List repositories')
.option('--owner <user>', 'List repos for a specific user (default: authenticated user)')
.option('--type <type>', 'Type: all|owner|public|private|member', 'all')
.option('--page <n>', 'Page number', '1')
.option('--per-page <n>', 'Results per page (max 100)', '20')
.option('--json', 'Output raw JSON')
.action(async (opts: { owner?: string; type?: string; page?: string; perPage?: string; json?: boolean }) => {
const token = getToken();
try {
let repos: GiteeRepo[];
const params = {
type: opts.type || 'all',
page: opts.page || 1,
per_page: opts.perPage || 20,
};
if (opts.owner) {
repos = await apiRequest<GiteeRepo[]>(`/users/${opts.owner}/repos`, { token, params });
} else {
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
repos = await apiRequest<GiteeRepo[]>('/user/repos', { token, params });
}
if (opts.json) {
console.log(JSON.stringify(repos, null, 2));
return;
}
if (!repos.length) {
console.log('No repositories found.');
return;
}
console.log(`Found ${repos.length} repositories:\n`);
for (const r of repos) {
const visibility = r.private ? '🔒 private' : '🌐 public';
const stars = r.stargazers_count !== undefined ? `${r.stargazers_count}` : '';
const forks = r.forks_count !== undefined ? `🍴 ${r.forks_count}` : '';
console.log(` ${r.full_name} [${visibility}] ${stars} ${forks}`);
if (r.description) console.log(` ${r.description}`);
console.log(` ${r.html_url}`);
}
} catch (err) {
handleError(err);
}
});
repo
.command('create <name>')
.description('Create a new repository')
.option('--private', 'Make repository private')
.option('--description <desc>', 'Repository description')
.option('--org <org>', 'Create under an organization')
.option('--json', 'Output raw JSON')
.action(async (name: string, opts: { private?: boolean; description?: string; org?: string; json?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
try {
const body: Record<string, unknown> = {
name,
private: opts.private || false,
description: opts.description || '',
auto_init: false,
};
let endpoint = '/user/repos';
if (opts.org) {
endpoint = `/orgs/${opts.org}/repos`;
}
const created = await apiRequest<GiteeRepo>(endpoint, {
method: 'POST',
token,
body,
});
if (opts.json) {
console.log(JSON.stringify(created, null, 2));
return;
}
console.log(`✓ Created repository: ${created.full_name}`);
console.log(` URL: ${created.html_url}`);
console.log(` Clone: ${created.clone_url || created.ssh_url}`);
} catch (err) {
handleError(err);
}
});
repo
.command('view [repo]')
.description('View repository details (owner/repo or auto-detect)')
.option('--repo <owner/repo>', 'Repository (owner/repo)')
.option('--json', 'Output raw JSON')
.action(async (repoArg: string | undefined, opts: { repo?: string; json?: boolean }) => {
const token = getToken();
const repoName = resolveRepo(repoArg || opts.repo);
const [owner, name] = repoName.split('/');
try {
const r = await apiRequest<GiteeRepo>(`/repos/${owner}/${name}`, { token });
if (opts.json) {
console.log(JSON.stringify(r, null, 2));
return;
}
const visibility = r.private ? '🔒 private' : '🌐 public';
console.log(`${r.full_name} (${visibility})`);
console.log(`─────────────────────────────`);
if (r.description) console.log(`Description: ${r.description}`);
console.log(`URL: ${r.html_url}`);
console.log(`Clone: ${r.clone_url || r.ssh_url}`);
console.log(`Branch: ${r.default_branch || 'master'}`);
console.log(`Stars: ${r.stargazers_count ?? 0} Forks: ${r.forks_count ?? 0} Watchers: ${r.watchers_count ?? 0}`);
console.log(`Issues: ${r.open_issues_count ?? 0} open`);
if (r.language) console.log(`Language: ${r.language}`);
console.log(`Created: ${formatDate(r.created_at)}`);
console.log(`Updated: ${formatDate(r.updated_at)}`);
} catch (err) {
handleError(err);
}
});
repo
.command('clone <repo>')
.description('Clone a Gitee repository (owner/repo)')
.action(async (repoArg: string) => {
const cloneUrl = `https://gitee.com/${repoArg}.git`;
console.log(`Cloning ${cloneUrl}...`);
try {
execSync(`git clone ${cloneUrl}`, { stdio: 'inherit' });
} catch {
console.error('Error: Clone failed.');
process.exit(1);
}
});
repo
.command('delete <repo>')
.description('Delete a repository (owner/repo) — requires confirmation')
.option('--yes', 'Skip confirmation prompt')
.action(async (repoArg: string, opts: { yes?: boolean }) => {
const token = getToken();
if (!token) {
console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.');
process.exit(1);
}
const [owner, name] = repoArg.split('/');
if (!owner || !name) {
console.error('Error: Invalid repo format. Use owner/repo.');
process.exit(1);
}
if (!opts.yes) {
const confirmed = await promptConfirm(`Are you sure you want to delete ${repoArg}? This is irreversible! (y/N): `);
if (!confirmed) {
console.log('Aborted.');
return;
}
}
try {
await apiRequest(`/repos/${owner}/${name}`, { method: 'DELETE', token });
console.log(`✓ Deleted repository: ${repoArg}`);
} catch (err) {
handleError(err);
}
});
}

40
src/config.ts Normal file
View File

@ -0,0 +1,40 @@
import { homedir } from 'os';
import { join } from 'path';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
export interface Config {
token?: string;
username?: string;
}
const CONFIG_DIR = join(homedir(), '.config', 'gitee-cli');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
export function getConfig(): Config {
try {
if (existsSync(CONFIG_FILE)) {
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
}
} catch {
// ignore
}
return {};
}
export function saveConfig(config: Config): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
}
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
}
export function clearConfig(): void {
if (existsSync(CONFIG_FILE)) {
unlinkSync(CONFIG_FILE);
}
}
export function getToken(): string | undefined {
// env var has higher priority
return process.env.GITEE_TOKEN || getConfig().token;
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}