harveyz-skill 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/skills/meta/project-release/SKILL.md +224 -0
- package/skills/meta/project-release/references/release-profile-template.md +19 -0
- package/skills-index.json +3 -3
- package/tools/hub/hub/cli/__pycache__/projects.cpython-314.pyc +0 -0
- package/tools/hub/hub/cli/projects.py +22 -0
- package/tools/hub/hub/core/__pycache__/projects.cpython-314.pyc +0 -0
- package/tools/hub/hub/core/projects.py +58 -0
- package/tools/hub/hub/tui/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/__pycache__/git.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/app.py +2 -2
- package/tools/hub/hub/tui/panels/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/panels/__pycache__/git.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/panels/__pycache__/projects.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/panels/__pycache__/tasks.cpython-314.pyc +0 -0
- package/tools/hub/hub/tui/panels/projects.py +52 -2
- package/tools/hub/hub/tui/panels/tasks.py +2 -2
- package/tools/hub/hub.sh +12 -0
- package/tools/hub/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_cli.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_db.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_migrate.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_projects.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_scan.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_tasks.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_tui_app.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_tui_git.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_tui_git_panel.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_tui_projects_panel.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/__pycache__/test_tui_tasks_panel.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/hub/tests/test_scan.py +89 -0
- package/tools/hub/tests/test_tui_app.py +1 -1
- package/tools/hub/tests/test_tui_projects_panel.py +62 -1
- package/tools/hub/tests/test_tui_tasks_panel.py +5 -5
- package/skills/meta/npm-release/SKILL.md +0 -214
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.2] - 2026-06-15
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- `project-release`:description 字段改为英文,符合 skill-publish F3 格式规范
|
|
14
|
+
- `project-release`:移除 `npm-release` skill,以 `.hskill/release-profile.md` 替代,消除重复
|
|
15
|
+
|
|
10
16
|
## [0.14.1] - 2026-06-14
|
|
11
17
|
|
|
12
18
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harveyz-skill",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Skill manager for Claude Code, Cursor, and Codex",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"skills/meta/git-cleanup/",
|
|
39
39
|
"skills/meta/contribute-skill/",
|
|
40
40
|
"skills/meta/skill-publish/",
|
|
41
|
-
"skills/meta/npm-release/",
|
|
42
41
|
"skills/meta/opencode-runner/",
|
|
42
|
+
"skills/meta/project-release/",
|
|
43
43
|
"tools/p-launch/",
|
|
44
44
|
"tools/todo-tool/",
|
|
45
45
|
"tools/hub/",
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: project-release
|
|
3
|
+
description: "Universal project release skill. Triggers on: release, bump version, publish, new version, cut release. Two phases: Init (first run — scans project and generates .hskill/release-profile.md); Execute (daily use — reads profile and runs full release flow). Works with npm, Python, Rust, Java, or any versioned project."
|
|
4
|
+
user_invocable: true
|
|
5
|
+
version: "1.1.0"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# project-release
|
|
9
|
+
|
|
10
|
+
通用项目发布 skill,两个阶段:
|
|
11
|
+
|
|
12
|
+
- **Init** — 扫描项目,和用户确认,生成 `.hskill/release-profile.md`(每个项目做一次)
|
|
13
|
+
- **Execute** — 读取 profile,完全按照它走发布流程
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 入口判断
|
|
18
|
+
|
|
19
|
+
检查 `.hskill/release-profile.md` 是否存在:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
ls .hskill/release-profile.md 2>/dev/null && echo "EXISTS" || echo "NOT_FOUND"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **NOT_FOUND** → 执行 Init 阶段
|
|
26
|
+
- **EXISTS** → 执行 Execute 阶段
|
|
27
|
+
- 用户明确说「重新初始化」→ 执行 Init 阶段(覆盖旧 profile)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Init 阶段
|
|
32
|
+
|
|
33
|
+
目标:把这个项目的发布规则搞清楚,写进 profile,之后每次发版直接读。**宁可问多几个问题,也不要猜错。**
|
|
34
|
+
|
|
35
|
+
### Step I-1 — 扫描分支拓扑
|
|
36
|
+
|
|
37
|
+
依次从以下来源收集信息:
|
|
38
|
+
|
|
39
|
+
1. 项目配置文件:`workflow-config.yml`、`CLAUDE.md`、`AGENTS.md`、`CONTRIBUTING.md`
|
|
40
|
+
2. git 历史和现有分支:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git branch -a
|
|
44
|
+
git log --merges --oneline -20
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
整理出:**发版起点分支**(从哪个分支开始操作)、**合并流向**(依次合并到哪些分支)、**保护分支**(不能直接提交的分支)。
|
|
48
|
+
|
|
49
|
+
不确定时直接问用户,用自然语言描述即可,不需要套用固定模型名。
|
|
50
|
+
|
|
51
|
+
### Step I-2 — 扫描版本文件
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 扫描常见版本文件
|
|
55
|
+
for f in package.json pyproject.toml Cargo.toml setup.cfg setup.py \
|
|
56
|
+
build.gradle build.gradle.kts pom.xml pubspec.yaml \
|
|
57
|
+
VERSION version.txt CHANGELOG.md CHANGELOG.rst; do
|
|
58
|
+
[ -f "$f" ] && echo "FOUND: $f"
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
# 补充搜索其他可能含版本号的文件
|
|
62
|
+
grep -rl '"version":\|^version\s*[=:]\|<version>' \
|
|
63
|
+
--include="*.json" --include="*.toml" --include="*.yaml" \
|
|
64
|
+
--include="*.yml" --include="*.xml" \
|
|
65
|
+
--exclude-dir=node_modules --exclude-dir=.git \
|
|
66
|
+
--exclude-dir=target --exclude-dir=dist . 2>/dev/null | head -20
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
对找到的每个文件,确认:
|
|
70
|
+
- 这个文件需要在发版时更新吗?(有些是 lockfile,由工具自动更新)
|
|
71
|
+
- 怎么更新?(工具命令 vs 直接编辑)
|
|
72
|
+
|
|
73
|
+
有不确定的就问用户。最终形成一个**有序清单**:哪些文件、按什么顺序、用什么方式更新。
|
|
74
|
+
|
|
75
|
+
### Step I-3 — 检测发布方式
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# CI/CD 配置
|
|
79
|
+
ls .github/workflows/ .gitlab-ci.yml .circleci/ 2>/dev/null
|
|
80
|
+
|
|
81
|
+
# 包管理器信息
|
|
82
|
+
[ -f package.json ] && cat package.json | grep -E '"private"|"publishConfig"'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
询问用户:
|
|
86
|
+
- 产物发往哪里(npm / PyPI / crates.io / 内部仓库 / 只打 tag / CI 自动处理)?
|
|
87
|
+
- 发布命令是什么?
|
|
88
|
+
- 谁来执行(用户手动 / CI 触发)?
|
|
89
|
+
|
|
90
|
+
### Step I-4 — 询问其他项目惯例
|
|
91
|
+
|
|
92
|
+
了解这个项目是否有:
|
|
93
|
+
- release 分支命名规范(`release/x.y.z` / `release-x.y.z` / 其他)
|
|
94
|
+
- commit message 格式要求
|
|
95
|
+
- tag 格式(`vX.Y.Z` / `X.Y.Z` / 其他)、是否要求 annotated tag
|
|
96
|
+
- 发版前必须通过的检查(测试、lint、构建)
|
|
97
|
+
- CHANGELOG 有无、格式如何(Keep a Changelog / 自定义 / 无)
|
|
98
|
+
|
|
99
|
+
### Step I-5 — 生成 profile
|
|
100
|
+
|
|
101
|
+
读取 `references/release-profile-template.md` 作为骨架,把 I-1 到 I-4 收集到的信息填进去。
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
mkdir -p .hskill
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
模板只提供四个顶级节(分支模型、版本文件、发布方式、特殊规则),每节的具体内容完全由项目决定,用最能清楚表达意图的方式写——几行文字、表格、命令示例均可。没有特殊规则就删掉那节。
|
|
108
|
+
|
|
109
|
+
写完后给用户看,确认无误再保存。告诉用户:**`.hskill/release-profile.md` 可以随时手动编辑**,是下次发版的唯一依据。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Execute 阶段
|
|
114
|
+
|
|
115
|
+
**读取 `.hskill/release-profile.md`,完全按照它执行。** profile 是唯一依据,不在 profile 里的事不要自己发明。
|
|
116
|
+
|
|
117
|
+
### Step E-0 — 读取 profile + 前置检查
|
|
118
|
+
|
|
119
|
+
先完整读取 profile,理解:发版起点分支是什么、版本文件清单是什么、合并流向是什么、发布命令是什么。
|
|
120
|
+
|
|
121
|
+
然后按 profile 要求做前置检查,通常包括:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# 当前分支是否是发版起点
|
|
125
|
+
git branch --show-current
|
|
126
|
+
|
|
127
|
+
# 是否有未提交的改动
|
|
128
|
+
git status --porcelain
|
|
129
|
+
|
|
130
|
+
# 是否与远端同步
|
|
131
|
+
git fetch origin
|
|
132
|
+
git rev-list --count HEAD..origin/$(git branch --show-current)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
profile 中有「发版前检查」(如跑测试)就一并执行。有问题立即停下告知用户。
|
|
136
|
+
|
|
137
|
+
### Step E-1 — 确定新版本号
|
|
138
|
+
|
|
139
|
+
从 profile 的版本文件清单中第一个「主版本声明文件」读取当前版本。
|
|
140
|
+
|
|
141
|
+
如果项目有 CHANGELOG,展示其中 Unreleased 部分的内容;没有则询问用户本次改动摘要。
|
|
142
|
+
|
|
143
|
+
根据改动建议 semver 升级类型,等用户确认后确定 NEW_VERSION:
|
|
144
|
+
|
|
145
|
+
| 类型 | 适用情况 |
|
|
146
|
+
|------|---------|
|
|
147
|
+
| patch | 只有 bugfix / docs / chore |
|
|
148
|
+
| minor | 新功能,向后兼容 |
|
|
149
|
+
| major | Breaking Change |
|
|
150
|
+
|
|
151
|
+
### Step E-2 — 更新 CHANGELOG(如适用)
|
|
152
|
+
|
|
153
|
+
如果 profile 中记录了 CHANGELOG 文件,按该文件的**现有格式**更新——把本次版本从 Unreleased 区域移动到有版本号的区段,保留一个空的 Unreleased 区域供下次使用。
|
|
154
|
+
|
|
155
|
+
不同项目格式不同,关键是保持和现有格式一致,不要改变风格。
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
TODAY=$(date +%Y-%m-%d)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Step E-3 — 更新版本文件
|
|
162
|
+
|
|
163
|
+
按 profile 中版本文件清单的顺序,用 profile 里说明的方式逐一更新每个文件。
|
|
164
|
+
|
|
165
|
+
更新完后用 `git diff` 确认所有文件已正确改动,没有遗漏或多余修改。
|
|
166
|
+
|
|
167
|
+
### Step E-4 — 执行 profile 中的发版前脚本(如有)
|
|
168
|
+
|
|
169
|
+
profile「特殊规则」里列出的、需要在提交前执行的脚本或命令,在这里执行。
|
|
170
|
+
|
|
171
|
+
### Step E-5 — 创建 release 分支并提交
|
|
172
|
+
|
|
173
|
+
按 profile 中的分支命名规范创建 release 分支,提交所有版本文件改动:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git checkout -b <profile 中的 release 分支格式,如 release/NEW_VERSION>
|
|
177
|
+
git add <profile 中列出的所有版本文件>
|
|
178
|
+
git commit -m "<profile 中的 commit message 格式>"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Step E-6 — 按合并流向执行本地合并 + 打 tag
|
|
182
|
+
|
|
183
|
+
读取 profile 中的合并流向,逐步执行本地合并。在最终目标分支(通常是 main/master)上打 tag:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
git tag <profile 中的 tag 格式,如 vNEW_VERSION 或 NEW_VERSION> -m "<tag message>"
|
|
187
|
+
# 若 profile 要求 annotated tag,加 -a 参数
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
所有操作只在本地做,不推送。
|
|
191
|
+
|
|
192
|
+
### Step E-7 — 输出待执行清单
|
|
193
|
+
|
|
194
|
+
本地操作完成后,整理出推送和发布指令供用户手动执行:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
== 本地已完成,待执行 ==
|
|
198
|
+
|
|
199
|
+
# 推送(按 profile 中的合并流向列出所有需要推送的分支)
|
|
200
|
+
git push origin <branch1>
|
|
201
|
+
git push origin <branch2>
|
|
202
|
+
git push origin <tag>
|
|
203
|
+
|
|
204
|
+
# 发布
|
|
205
|
+
<profile 中的发布命令>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Step E-8 — 收尾
|
|
209
|
+
|
|
210
|
+
用户确认成功后输出摘要,列出:版本变更(旧 → 新)、CHANGELOG 更新情况、tag、已推送分支、发布目标。
|
|
211
|
+
|
|
212
|
+
可选:询问是否清理 release 分支(本地 + 远端)。
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 常见问题
|
|
217
|
+
|
|
218
|
+
**profile 信息有误** — 直接编辑 `.hskill/release-profile.md` 后重新触发 Execute。
|
|
219
|
+
|
|
220
|
+
**发版前检查失败** — 按失败信息修复后,重新从 E-0 开始。
|
|
221
|
+
|
|
222
|
+
**版本文件漏更新** — E-3 后用 `git diff` 检查,发现遗漏立即补上。
|
|
223
|
+
|
|
224
|
+
**CHANGELOG 为空或不存在** — 询问用户本次改动内容,或确认跳过 CHANGELOG 更新。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Release Profile
|
|
2
|
+
<!-- 由 project-release skill Init 阶段生成,可随时手动编辑 -->
|
|
3
|
+
<!-- 生成时间:{YYYY-MM-DD} -->
|
|
4
|
+
|
|
5
|
+
## 分支模型
|
|
6
|
+
|
|
7
|
+
<!-- 描述这个项目的分支结构和发版流向,用自然语言或图示均可 -->
|
|
8
|
+
|
|
9
|
+
## 版本文件
|
|
10
|
+
|
|
11
|
+
<!-- 列出发版时需要更新哪些文件、怎么更新。具体到命令或操作步骤 -->
|
|
12
|
+
|
|
13
|
+
## 发布方式
|
|
14
|
+
|
|
15
|
+
<!-- 怎么把产物发出去:命令、工具、谁来执行 -->
|
|
16
|
+
|
|
17
|
+
## 特殊规则
|
|
18
|
+
|
|
19
|
+
<!-- 这个项目发版时有哪些需要注意的事情,没有就删掉这节 -->
|
package/skills-index.json
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"coding": "程序工具(git-workflow-init + full-stack-debug-env + pm-task-dispatch + task-close)",
|
|
25
25
|
"writing": "写作工具(doc-forge + diagram + diataxis-docs + dir-manage + migrate-specs)",
|
|
26
26
|
"design": "设计工具(brand-scout + style-build + sync-design-html)",
|
|
27
|
-
"meta": "元操作工具(skill-analyzer + git-cleanup + contribute-skill + skill-publish +
|
|
27
|
+
"meta": "元操作工具(skill-analyzer + git-cleanup + contribute-skill + skill-publish + project-release)"
|
|
28
28
|
},
|
|
29
29
|
"skills": [
|
|
30
30
|
{ "path": "research/article-fetcher", "bundle": "research" },
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
{ "path": "meta/git-cleanup", "bundle": "meta" },
|
|
50
50
|
{ "path": "meta/contribute-skill", "bundle": "meta" },
|
|
51
51
|
{ "path": "meta/skill-publish", "bundle": "meta" },
|
|
52
|
-
{ "path": "meta/
|
|
53
|
-
{ "path": "meta/
|
|
52
|
+
{ "path": "meta/opencode-runner", "bundle": "meta" },
|
|
53
|
+
{ "path": "meta/project-release", "bundle": "meta" }
|
|
54
54
|
],
|
|
55
55
|
"hooks": [
|
|
56
56
|
{
|
|
Binary file
|
|
@@ -8,6 +8,7 @@ import typer
|
|
|
8
8
|
|
|
9
9
|
from hub.core.db import HubDB
|
|
10
10
|
from hub.core import projects as proj
|
|
11
|
+
from hub.core.projects import scan_projects
|
|
11
12
|
|
|
12
13
|
app = typer.Typer(no_args_is_help=True)
|
|
13
14
|
|
|
@@ -98,3 +99,24 @@ def projects_sync(json_out: bool = typer.Option(False, "--json")):
|
|
|
98
99
|
typer.echo(f"✓ Scanned {len(repos)} repos")
|
|
99
100
|
except ImportError:
|
|
100
101
|
_err("p-launch not installed; cannot auto-scan", json_out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("scan")
|
|
105
|
+
def projects_scan(
|
|
106
|
+
dirs: list[str] = typer.Argument(..., help="Directories to scan for git repos"),
|
|
107
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
108
|
+
):
|
|
109
|
+
"""Scan directories for git repos and register them as projects."""
|
|
110
|
+
db = HubDB()
|
|
111
|
+
result = scan_projects(dirs, db, _md_path())
|
|
112
|
+
if json_out:
|
|
113
|
+
_out(result, json_out)
|
|
114
|
+
return
|
|
115
|
+
for p in result["added"]:
|
|
116
|
+
typer.echo(f"✓ added {p['name']:<24} {p['path']}")
|
|
117
|
+
for name in result["skipped"]:
|
|
118
|
+
typer.echo(f"· skipped {name:<24} (already registered)")
|
|
119
|
+
for f in result["failed"]:
|
|
120
|
+
typer.echo(f"✗ failed {f['path']} ({f['reason']})")
|
|
121
|
+
a, s, f = len(result["added"]), len(result["skipped"]), len(result["failed"])
|
|
122
|
+
typer.echo(f"\nScanned: {a} added, {s} skipped, {f} failed")
|
|
Binary file
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fcntl
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
2
4
|
from datetime import datetime, timezone
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
@@ -63,3 +65,59 @@ def get_project_path(db, name: str) -> str | None:
|
|
|
63
65
|
"SELECT path FROM projects WHERE name = ?", (name,)
|
|
64
66
|
).fetchone()
|
|
65
67
|
return row["path"] if row else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_name(repo_path: Path) -> str:
|
|
71
|
+
"""Resolve project name from git remote origin URL, fall back to dir name."""
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["git", "remote", "get-url", "origin"],
|
|
75
|
+
cwd=repo_path,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
timeout=5,
|
|
79
|
+
)
|
|
80
|
+
if result.returncode == 0:
|
|
81
|
+
url = result.stdout.strip().rstrip("/")
|
|
82
|
+
if url.endswith(".git"):
|
|
83
|
+
url = url[:-4]
|
|
84
|
+
return re.split(r"[/:]", url)[-1]
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return repo_path.name
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def scan_projects(
|
|
91
|
+
dirs: list[str],
|
|
92
|
+
db,
|
|
93
|
+
md_path: Path = _DEFAULT_MD,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Scan directories (direct subdirectories (one level deep)) for git repos and register them as projects.
|
|
96
|
+
|
|
97
|
+
Returns {"added": [...], "skipped": [...], "failed": [...]}.
|
|
98
|
+
Existing projects (by name) are skipped without modification.
|
|
99
|
+
"""
|
|
100
|
+
added: list[dict] = []
|
|
101
|
+
skipped: list[str] = []
|
|
102
|
+
failed: list[dict] = []
|
|
103
|
+
existing = {p["name"] for p in list_projects(db)}
|
|
104
|
+
|
|
105
|
+
for d in dirs:
|
|
106
|
+
p = Path(d).expanduser()
|
|
107
|
+
if not p.exists():
|
|
108
|
+
failed.append({"path": str(d), "reason": "directory not found"})
|
|
109
|
+
continue
|
|
110
|
+
for git_dir in sorted(p.glob("*/.git")):
|
|
111
|
+
repo_path = git_dir.parent
|
|
112
|
+
name = _resolve_name(repo_path)
|
|
113
|
+
if name in existing:
|
|
114
|
+
skipped.append(name)
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
add_project(db, name, path=str(repo_path), md_path=md_path)
|
|
118
|
+
added.append({"name": name, "path": str(repo_path)})
|
|
119
|
+
existing.add(name)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
failed.append({"path": str(repo_path), "reason": str(e)})
|
|
122
|
+
|
|
123
|
+
return {"added": added, "skipped": skipped, "failed": failed}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tools/hub/hub/tui/app.py
CHANGED
|
@@ -28,9 +28,9 @@ class HubApp(App):
|
|
|
28
28
|
|
|
29
29
|
BINDINGS = [
|
|
30
30
|
Binding("tab", "cycle_focus", "Switch col", show=True),
|
|
31
|
-
Binding("f", "fetch", "Fetch", show=True),
|
|
31
|
+
Binding("ctrl+f", "fetch", "Fetch", show=True),
|
|
32
32
|
Binding("enter", "open_project", "Open", show=True),
|
|
33
|
-
Binding("q", "quit", "Quit", show=True),
|
|
33
|
+
Binding("ctrl+q", "quit", "Quit", show=True),
|
|
34
34
|
]
|
|
35
35
|
|
|
36
36
|
def __init__(self) -> None:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from textual import work
|
|
1
5
|
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
2
7
|
from textual.message import Message
|
|
3
8
|
from textual.widget import Widget
|
|
4
|
-
from textual.widgets import Label, ListItem, ListView
|
|
9
|
+
from textual.widgets import Input, Label, ListItem, ListView
|
|
5
10
|
|
|
6
11
|
from hub.core.db import HubDB
|
|
7
|
-
from hub.core.projects import list_projects
|
|
12
|
+
from hub.core.projects import list_projects, scan_projects
|
|
8
13
|
from hub.core.tasks import list_tasks
|
|
9
14
|
|
|
15
|
+
_DEFAULT_MD = Path.home() / ".hskill" / "public" / "PROJECTS.md"
|
|
16
|
+
|
|
10
17
|
|
|
11
18
|
class ProjectsPanel(Widget):
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("ctrl+s", "scan_action", "Scan", show=True),
|
|
21
|
+
]
|
|
22
|
+
|
|
12
23
|
DEFAULT_CSS = """
|
|
13
24
|
ProjectsPanel {
|
|
14
25
|
width: 30;
|
|
@@ -18,6 +29,9 @@ class ProjectsPanel(Widget):
|
|
|
18
29
|
ProjectsPanel:focus-within {
|
|
19
30
|
border: solid $accent;
|
|
20
31
|
}
|
|
32
|
+
ProjectsPanel Input {
|
|
33
|
+
dock: bottom;
|
|
34
|
+
}
|
|
21
35
|
"""
|
|
22
36
|
|
|
23
37
|
class ProjectSelected(Message):
|
|
@@ -56,3 +70,39 @@ class ProjectsPanel(Widget):
|
|
|
56
70
|
self.selected_name = p["name"]
|
|
57
71
|
self.selected_path = p.get("path", "")
|
|
58
72
|
self.post_message(self.ProjectSelected(p["name"], p.get("path", "")))
|
|
73
|
+
|
|
74
|
+
def action_scan_action(self) -> None:
|
|
75
|
+
if self.query("#scan-dir-input"):
|
|
76
|
+
return
|
|
77
|
+
inp = Input(
|
|
78
|
+
placeholder="Directory to scan… (Enter to scan, Esc to cancel)",
|
|
79
|
+
id="scan-dir-input",
|
|
80
|
+
)
|
|
81
|
+
self.mount(inp)
|
|
82
|
+
inp.focus()
|
|
83
|
+
|
|
84
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
85
|
+
if event.input.id != "scan-dir-input":
|
|
86
|
+
return
|
|
87
|
+
directory = event.value.strip()
|
|
88
|
+
event.input.remove()
|
|
89
|
+
if directory:
|
|
90
|
+
self._scan_worker(directory)
|
|
91
|
+
|
|
92
|
+
def on_key(self, event) -> None:
|
|
93
|
+
if event.key == "escape" and self.query("#scan-dir-input"):
|
|
94
|
+
self.query_one("#scan-dir-input").remove()
|
|
95
|
+
event.prevent_default()
|
|
96
|
+
|
|
97
|
+
@work(thread=True)
|
|
98
|
+
def _scan_worker(self, directory: str) -> None:
|
|
99
|
+
md = Path(os.environ["HUB_MD_PATH"]) if "HUB_MD_PATH" in os.environ else _DEFAULT_MD
|
|
100
|
+
result = scan_projects([directory], self.db, md_path=md)
|
|
101
|
+
a = len(result["added"])
|
|
102
|
+
s = len(result["skipped"])
|
|
103
|
+
f = len(result["failed"])
|
|
104
|
+
self.app.call_from_thread(self._after_scan, a, s, f)
|
|
105
|
+
|
|
106
|
+
def _after_scan(self, added: int, skipped: int, failed: int) -> None:
|
|
107
|
+
self._reload()
|
|
108
|
+
self.app.notify(f"Scanned: {added} added, {skipped} skipped, {failed} failed")
|
|
@@ -9,9 +9,9 @@ from hub.core.tasks import add_task, delete_task, list_tasks, mark_done, update_
|
|
|
9
9
|
|
|
10
10
|
class TasksPanel(Widget):
|
|
11
11
|
BINDINGS = [
|
|
12
|
-
Binding("n", "new_task", "New", show=True),
|
|
12
|
+
Binding("ctrl+n", "new_task", "New", show=True),
|
|
13
13
|
Binding("space", "toggle_done", "Done", show=True),
|
|
14
|
-
Binding("
|
|
14
|
+
Binding("ctrl+d", "delete_task_action", "Delete", show=True),
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
DEFAULT_CSS = """
|
package/tools/hub/hub.sh
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -e
|
|
3
3
|
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
|
|
6
|
+
# Auto-detect dev mode: script is running from the source tree
|
|
7
|
+
if [ -d "${SCRIPT_DIR}/hub" ] && [ -f "${SCRIPT_DIR}/pyproject.toml" ]; then
|
|
8
|
+
DEV_VENV="${SCRIPT_DIR}/.venv"
|
|
9
|
+
if [ ! -x "${DEV_VENV}/bin/hub" ]; then
|
|
10
|
+
python3 -m venv "${DEV_VENV}"
|
|
11
|
+
"${DEV_VENV}/bin/pip" install -q -e "${SCRIPT_DIR}"
|
|
12
|
+
fi
|
|
13
|
+
exec "${DEV_VENV}/bin/hub" "$@"
|
|
14
|
+
fi
|
|
15
|
+
|
|
4
16
|
VENV_DIR="${HOME}/.hskill/tools/hub/venv"
|
|
5
17
|
INSTALL_DIR="${HOME}/.hskill/tools/hub"
|
|
6
18
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import pytest
|
|
5
|
+
from hub.core.db import HubDB
|
|
6
|
+
from hub.core.projects import scan_projects
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_repo(path: Path, origin: str | None = None) -> Path:
|
|
10
|
+
"""Create a minimal git repo under path/."""
|
|
11
|
+
path.mkdir(parents=True)
|
|
12
|
+
subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
|
|
13
|
+
subprocess.run(
|
|
14
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
15
|
+
cwd=path, check=True, capture_output=True,
|
|
16
|
+
env={**os.environ, "GIT_AUTHOR_NAME": "t",
|
|
17
|
+
"GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
|
|
18
|
+
"GIT_COMMITTER_EMAIL": "t@t"},
|
|
19
|
+
)
|
|
20
|
+
if origin:
|
|
21
|
+
subprocess.run(
|
|
22
|
+
["git", "remote", "add", "origin", origin],
|
|
23
|
+
cwd=path, check=True, capture_output=True,
|
|
24
|
+
)
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_scan_adds_repo_using_remote_name(tmp_path):
|
|
29
|
+
_make_repo(tmp_path / "repo-a", origin="https://github.com/user/my-repo.git")
|
|
30
|
+
db = HubDB(db_path=tmp_path / "hub.db")
|
|
31
|
+
md_path = tmp_path / "PROJECTS.md"
|
|
32
|
+
result = scan_projects([str(tmp_path)], db, md_path=md_path)
|
|
33
|
+
assert len(result["added"]) == 1
|
|
34
|
+
assert result["added"][0]["name"] == "my-repo"
|
|
35
|
+
assert result["skipped"] == []
|
|
36
|
+
assert result["failed"] == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_scan_fallback_to_dir_name_when_no_remote(tmp_path):
|
|
40
|
+
_make_repo(tmp_path / "repo-b") # no origin
|
|
41
|
+
db = HubDB(db_path=tmp_path / "hub.db")
|
|
42
|
+
md_path = tmp_path / "PROJECTS.md"
|
|
43
|
+
result = scan_projects([str(tmp_path)], db, md_path=md_path)
|
|
44
|
+
assert result["added"][0]["name"] == "repo-b"
|
|
45
|
+
assert result["skipped"] == []
|
|
46
|
+
assert result["failed"] == []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_scan_skips_existing_project(tmp_path):
|
|
50
|
+
_make_repo(tmp_path / "repo-a", origin="https://github.com/user/repo-a.git")
|
|
51
|
+
db = HubDB(db_path=tmp_path / "hub.db")
|
|
52
|
+
md_path = tmp_path / "PROJECTS.md"
|
|
53
|
+
from hub.core.projects import add_project
|
|
54
|
+
add_project(db, "repo-a", path="/old/path", md_path=md_path)
|
|
55
|
+
result = scan_projects([str(tmp_path)], db, md_path=md_path)
|
|
56
|
+
assert "repo-a" in result["skipped"]
|
|
57
|
+
assert result["added"] == []
|
|
58
|
+
# Verify old path was NOT overwritten
|
|
59
|
+
from hub.core.projects import get_project_path
|
|
60
|
+
assert get_project_path(db, "repo-a") == "/old/path"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_scan_adds_repo_using_ssh_remote_name(tmp_path):
|
|
64
|
+
_make_repo(tmp_path / "repo-c", origin="git@github.com:user/ssh-repo.git")
|
|
65
|
+
db = HubDB(db_path=tmp_path / "hub.db")
|
|
66
|
+
result = scan_projects([str(tmp_path)], db, md_path=tmp_path / "PROJECTS.md")
|
|
67
|
+
assert result["added"][0]["name"] == "ssh-repo"
|
|
68
|
+
assert result["skipped"] == []
|
|
69
|
+
assert result["failed"] == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_scan_nonexistent_dir_goes_to_failed(tmp_path):
|
|
73
|
+
db = HubDB(db_path=tmp_path / "hub.db")
|
|
74
|
+
result = scan_projects(["/nonexistent/path/xyz"], db)
|
|
75
|
+
assert result["added"] == []
|
|
76
|
+
assert result["failed"][0]["reason"] == "directory not found"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_scan_multiple_dirs(tmp_path):
|
|
80
|
+
dir1 = tmp_path / "group1"
|
|
81
|
+
dir2 = tmp_path / "group2"
|
|
82
|
+
_make_repo(dir1 / "repo-x", origin="https://github.com/user/repo-x.git")
|
|
83
|
+
_make_repo(dir2 / "repo-y", origin="https://github.com/user/repo-y.git")
|
|
84
|
+
db = HubDB(db_path=tmp_path / "hub.db")
|
|
85
|
+
result = scan_projects([str(dir1), str(dir2)], db, md_path=tmp_path / "PROJECTS.md")
|
|
86
|
+
names = {p["name"] for p in result["added"]}
|
|
87
|
+
assert names == {"repo-x", "repo-y"}
|
|
88
|
+
assert result["skipped"] == []
|
|
89
|
+
assert result["failed"] == []
|
|
@@ -16,7 +16,7 @@ async def test_hub_app_mounts(tmp_path, monkeypatch):
|
|
|
16
16
|
async def test_hub_app_quit(tmp_path, monkeypatch):
|
|
17
17
|
monkeypatch.setenv("HUB_DB_PATH", str(tmp_path / "hub.db"))
|
|
18
18
|
async with HubApp().run_test() as pilot:
|
|
19
|
-
await pilot.press("q")
|
|
19
|
+
await pilot.press("ctrl+q")
|
|
20
20
|
# If we got here without hanging, the app quit cleanly.
|
|
21
21
|
assert True
|
|
22
22
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
from textual.app import App, ComposeResult
|
|
3
|
-
from textual.widgets import ListItem
|
|
3
|
+
from textual.widgets import Input, ListItem
|
|
4
4
|
|
|
5
5
|
from hub.core.db import HubDB
|
|
6
6
|
from hub.core.projects import add_project
|
|
@@ -51,6 +51,67 @@ async def test_projects_panel_posts_selected_message(tmp_path):
|
|
|
51
51
|
assert any(m.name == "myrepo" for m in received)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
async def test_projects_panel_scan_input_appears(tmp_path):
|
|
55
|
+
db = HubDB(tmp_path / "hub.db")
|
|
56
|
+
async with _make_app(db).run_test() as pilot:
|
|
57
|
+
panel = pilot.app.query_one(ProjectsPanel)
|
|
58
|
+
panel.focus()
|
|
59
|
+
await pilot.press("ctrl+s")
|
|
60
|
+
await pilot.pause()
|
|
61
|
+
assert len(panel.query(Input)) == 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_projects_panel_scan_esc_cancels(tmp_path):
|
|
65
|
+
db = HubDB(tmp_path / "hub.db")
|
|
66
|
+
async with _make_app(db).run_test() as pilot:
|
|
67
|
+
panel = pilot.app.query_one(ProjectsPanel)
|
|
68
|
+
panel.focus()
|
|
69
|
+
await pilot.press("ctrl+s")
|
|
70
|
+
await pilot.pause()
|
|
71
|
+
await pilot.press("escape")
|
|
72
|
+
await pilot.pause()
|
|
73
|
+
assert len(panel.query(Input)) == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_projects_panel_scan_submit_triggers_worker(tmp_path):
|
|
77
|
+
"""Submitting the scan Input calls _scan_worker with the typed directory."""
|
|
78
|
+
db = HubDB(tmp_path / "hub.db")
|
|
79
|
+
calls: list[str] = []
|
|
80
|
+
|
|
81
|
+
async with _make_app(db).run_test() as pilot:
|
|
82
|
+
panel = pilot.app.query_one(ProjectsPanel)
|
|
83
|
+
panel.focus()
|
|
84
|
+
await pilot.press("ctrl+s")
|
|
85
|
+
await pilot.pause()
|
|
86
|
+
|
|
87
|
+
inp = panel.query_one("#scan-dir-input", Input)
|
|
88
|
+
panel._scan_worker = lambda d: calls.append(d) # type: ignore[method-assign]
|
|
89
|
+
|
|
90
|
+
from textual.widgets import Input as _Input
|
|
91
|
+
panel.on_input_submitted(_Input.Submitted(inp, str(tmp_path)))
|
|
92
|
+
await pilot.pause()
|
|
93
|
+
|
|
94
|
+
assert calls == [str(tmp_path)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_projects_panel_after_scan_reloads_list(tmp_path):
|
|
98
|
+
"""_after_scan reloads the project list and shows a notification."""
|
|
99
|
+
db = HubDB(tmp_path / "hub.db")
|
|
100
|
+
add_project(db, "pre-existing", path="/old")
|
|
101
|
+
|
|
102
|
+
async with _make_app(db).run_test() as pilot:
|
|
103
|
+
panel = pilot.app.query_one(ProjectsPanel)
|
|
104
|
+
await pilot.pause()
|
|
105
|
+
assert len(panel.query(ListItem)) == 1
|
|
106
|
+
|
|
107
|
+
# Simulate a scan that added "new-repo" directly in the DB
|
|
108
|
+
add_project(db, "new-repo", path=str(tmp_path))
|
|
109
|
+
panel._after_scan(added=1, skipped=0, failed=0)
|
|
110
|
+
await pilot.pause()
|
|
111
|
+
|
|
112
|
+
assert len(panel.query(ListItem)) == 2
|
|
113
|
+
|
|
114
|
+
|
|
54
115
|
async def test_projects_panel_shows_task_count(tmp_path):
|
|
55
116
|
db = HubDB(tmp_path / "hub.db")
|
|
56
117
|
add_project(db, "proj", path="/tmp/proj")
|
|
@@ -62,7 +62,7 @@ async def test_tasks_panel_new_task_input_appears(tmp_path):
|
|
|
62
62
|
panel.refresh_project("proj")
|
|
63
63
|
await pilot.pause()
|
|
64
64
|
panel.focus()
|
|
65
|
-
await pilot.press("n")
|
|
65
|
+
await pilot.press("ctrl+n")
|
|
66
66
|
await pilot.pause()
|
|
67
67
|
assert len(panel.query(Input)) == 1
|
|
68
68
|
|
|
@@ -77,7 +77,7 @@ async def test_tasks_panel_new_task_saves(tmp_path):
|
|
|
77
77
|
panel.refresh_project("proj")
|
|
78
78
|
await pilot.pause()
|
|
79
79
|
panel.focus()
|
|
80
|
-
await pilot.press("n")
|
|
80
|
+
await pilot.press("ctrl+n")
|
|
81
81
|
await pilot.pause()
|
|
82
82
|
await pilot.press("M", "y", "space", "t", "a", "s", "k")
|
|
83
83
|
await pilot.press("enter")
|
|
@@ -126,11 +126,11 @@ async def test_tasks_panel_delete_two_press(tmp_path):
|
|
|
126
126
|
lst.focus()
|
|
127
127
|
await pilot.press("down")
|
|
128
128
|
# first D — should NOT delete yet
|
|
129
|
-
await pilot.press("
|
|
129
|
+
await pilot.press("ctrl+d")
|
|
130
130
|
await pilot.pause()
|
|
131
131
|
assert len(list_tasks(db, project="proj")) == 1
|
|
132
|
-
# second
|
|
133
|
-
await pilot.press("
|
|
132
|
+
# second ctrl+d — should delete
|
|
133
|
+
await pilot.press("ctrl+d")
|
|
134
134
|
await pilot.pause()
|
|
135
135
|
|
|
136
136
|
assert len(list_tasks(db, project="proj")) == 0
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: npm-release
|
|
3
|
-
description: "Complete npm publish workflow for harveyz-skill: bump version, update CHANGELOG, create release branch, merge to staging then main, tag, and publish to npm. Use this skill whenever the user wants to release, publish, cut a version, bump version, ship to npm, or deploy a new package version."
|
|
4
|
-
user_invocable: true
|
|
5
|
-
version: "1.2.0"
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# npm-release
|
|
9
|
-
|
|
10
|
-
harveyz-skill 发布到 npm 的完整流程:版本号升级 → CHANGELOG 更新 → 分支提交 → 本地合并到 staging 和 main → 打 tag → 给出推送 + 发布指令供用户执行。
|
|
11
|
-
|
|
12
|
-
**说明**:所有 `git push` 和 `npm publish` 操作由用户自行执行,Claude 只做本地操作,最后统一给出指令清单。
|
|
13
|
-
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
## 前置条件
|
|
17
|
-
|
|
18
|
-
在开始之前先做以下检查,发现问题立即停下来告知用户:
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
# 1. 确认在 staging 分支(发版从 staging 开始)
|
|
22
|
-
git branch --show-current
|
|
23
|
-
|
|
24
|
-
# 2. 确认没有未提交的改动
|
|
25
|
-
git status --porcelain
|
|
26
|
-
|
|
27
|
-
# 3. 确认本地 staging 和远端同步
|
|
28
|
-
git fetch origin staging
|
|
29
|
-
git rev-list --count HEAD..origin/staging # 应为 0
|
|
30
|
-
|
|
31
|
-
# 4. 确认测试通过
|
|
32
|
-
npm test
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
如果有未提交改动 → 告知用户先处理;如果测试失败 → 停止,展示失败信息。
|
|
36
|
-
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
## Step 1 — 确定版本号
|
|
40
|
-
|
|
41
|
-
先展示当前版本和 [Unreleased] 改动摘要:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
node -e "console.log(require('./package.json').version)"
|
|
45
|
-
# 摘取 CHANGELOG.md 中 ## [Unreleased] 下的内容
|
|
46
|
-
awk '/^## \[Unreleased\]/{f=1;next} /^## \[/{f=0} f{print}' CHANGELOG.md | head -30
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
根据 [Unreleased] 内容建议升级类型,然后**询问用户**确认:
|
|
50
|
-
|
|
51
|
-
| 类型 | 适用情况 |
|
|
52
|
-
|------|---------|
|
|
53
|
-
| patch | 只有 bugfix / docs / chore |
|
|
54
|
-
| minor | 新增功能,向后兼容 |
|
|
55
|
-
| major | 有 Breaking Change |
|
|
56
|
-
|
|
57
|
-
等用户确认后,计算新版本号(当前版本 + 升级类型)。
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## Step 2 — 更新 CHANGELOG
|
|
62
|
-
|
|
63
|
-
编辑 `CHANGELOG.md`,将 `## [Unreleased]` 节改为新版本节,同时在它上方保留空的 Unreleased 节:
|
|
64
|
-
|
|
65
|
-
**改动前(示例):**
|
|
66
|
-
```markdown
|
|
67
|
-
## [Unreleased]
|
|
68
|
-
|
|
69
|
-
### Added
|
|
70
|
-
- 新功能 X
|
|
71
|
-
|
|
72
|
-
## [0.9.0] - 2026-05-29
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
**改动后(示例,假设新版为 0.10.0,今天为 2026-06-08):**
|
|
76
|
-
```markdown
|
|
77
|
-
## [Unreleased]
|
|
78
|
-
|
|
79
|
-
## [0.10.0] - 2026-06-08
|
|
80
|
-
|
|
81
|
-
### Added
|
|
82
|
-
- 新功能 X
|
|
83
|
-
|
|
84
|
-
## [0.9.0] - 2026-05-29
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
日期用 `date +%Y-%m-%d` 获取当前日期。
|
|
88
|
-
|
|
89
|
-
---
|
|
90
|
-
|
|
91
|
-
## Step 3 — 更新 package.json
|
|
92
|
-
|
|
93
|
-
直接用 `npm version` 修改版本号(不打 tag,tag 在 Step 6 手动打):
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
npm version <new-version> --no-git-tag-version
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
确认 `package.json` 中 `"version"` 已更新为新版本号。
|
|
100
|
-
|
|
101
|
-
---
|
|
102
|
-
|
|
103
|
-
## Step 3.5 — 同步 .npmignore 和 package.json files 字段
|
|
104
|
-
|
|
105
|
-
运行生成脚本,确保 `.npmignore` 和 `package.json` 中的 `files` 字段与当前 `skills-index.json` 保持一致:
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
node scripts/generate-npmignore.js
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
检查输出有无报错。此步骤必须在提交前完成,否则新增或重命名的 skill 路径不会被正确包含/排除。
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## Step 4 — 创建 release 分支并提交
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
# 从当前 staging 切出 release 分支
|
|
119
|
-
git checkout -b release/<new-version>
|
|
120
|
-
|
|
121
|
-
# 提交 CHANGELOG、package.json、package-lock.json 和 .npmignore
|
|
122
|
-
git add CHANGELOG.md package.json package-lock.json .npmignore
|
|
123
|
-
git commit -m "chore(release): bump version to <new-version>"
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
---
|
|
127
|
-
|
|
128
|
-
## Step 5 — 本地合并到 staging(不推送)
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
git checkout staging
|
|
132
|
-
git merge release/<new-version>
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
只做本地合并,不执行 push。
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## Step 6 — 本地合并到 main 并打 tag(不推送)
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
git checkout main
|
|
143
|
-
git pull origin main # 只拉取,确保本地 main 是最新的
|
|
144
|
-
git merge staging
|
|
145
|
-
git tag -a v<new-version> -m "v<new-version>"
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
tag 使用 annotated tag(`-a`),符合项目 workflow-config.yml 的 `require_annotated: true` 规则。
|
|
149
|
-
|
|
150
|
-
**不执行任何 push**,推送操作统一在最后由用户执行。
|
|
151
|
-
|
|
152
|
-
---
|
|
153
|
-
|
|
154
|
-
## Step 7 — 给出最终执行清单
|
|
155
|
-
|
|
156
|
-
本地准备工作已完成。向用户展示以下指令,请用户依次确认并手动执行:
|
|
157
|
-
|
|
158
|
-
---
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
== 待执行:推送 + 发布 ==
|
|
162
|
-
|
|
163
|
-
# 1. 推送 staging
|
|
164
|
-
git push origin staging
|
|
165
|
-
|
|
166
|
-
# 2. 推送 main 和 tag
|
|
167
|
-
git push origin main
|
|
168
|
-
git push origin v<new-version>
|
|
169
|
-
|
|
170
|
-
# 3. 发布到 npm(需要已登录:npm login)
|
|
171
|
-
npm publish
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
---
|
|
175
|
-
|
|
176
|
-
说明:
|
|
177
|
-
- 如果 `npm login` 状态已过期,先运行 `npm login` 再执行 `npm publish`
|
|
178
|
-
- 如果 `git push origin main` 被 pre-push hook 拒绝,检查 tag 格式是否为 `v<X>.<Y>.<Z>`,以及是否是 annotated tag
|
|
179
|
-
- 以上命令需在项目根目录执行
|
|
180
|
-
|
|
181
|
-
用户执行完毕后,告知 Claude,Claude 会输出最终摘要。
|
|
182
|
-
|
|
183
|
-
---
|
|
184
|
-
|
|
185
|
-
## Step 8 — 收尾(用户执行完毕后)
|
|
186
|
-
|
|
187
|
-
用户确认推送和发布成功后输出摘要:
|
|
188
|
-
|
|
189
|
-
```
|
|
190
|
-
✓ 版本 v<new-version> 已发布
|
|
191
|
-
|
|
192
|
-
package.json <old-version> → <new-version>
|
|
193
|
-
CHANGELOG [Unreleased] → [<new-version>] - <date>
|
|
194
|
-
Git tag v<new-version>(已推送)
|
|
195
|
-
npm https://www.npmjs.com/package/harveyz-skill/v/<new-version>
|
|
196
|
-
分支 staging ✓ main ✓
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
可选:询问用户是否删除 release 分支(本地 + 远端):
|
|
200
|
-
|
|
201
|
-
```bash
|
|
202
|
-
git branch -d release/<new-version>
|
|
203
|
-
git push origin --delete release/<new-version> # 若已推送
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
---
|
|
207
|
-
|
|
208
|
-
## 常见问题
|
|
209
|
-
|
|
210
|
-
**`npm publish` 提示 403**:token 过期,运行 `npm login` 后重试。
|
|
211
|
-
|
|
212
|
-
**`git push` 被 pre-push hook 拒绝**:检查 tag 格式是否符合 `v<X>.<Y>.<Z>`,annotated tag 是必须的。
|
|
213
|
-
|
|
214
|
-
**[Unreleased] 为空**:没有新内容也能发版(通常是 patch),告知用户确认后继续。
|