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.
Files changed (37) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +2 -2
  3. package/skills/meta/project-release/SKILL.md +224 -0
  4. package/skills/meta/project-release/references/release-profile-template.md +19 -0
  5. package/skills-index.json +3 -3
  6. package/tools/hub/hub/cli/__pycache__/projects.cpython-314.pyc +0 -0
  7. package/tools/hub/hub/cli/projects.py +22 -0
  8. package/tools/hub/hub/core/__pycache__/projects.cpython-314.pyc +0 -0
  9. package/tools/hub/hub/core/projects.py +58 -0
  10. package/tools/hub/hub/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  11. package/tools/hub/hub/tui/__pycache__/app.cpython-314.pyc +0 -0
  12. package/tools/hub/hub/tui/__pycache__/git.cpython-314.pyc +0 -0
  13. package/tools/hub/hub/tui/app.py +2 -2
  14. package/tools/hub/hub/tui/panels/__pycache__/__init__.cpython-314.pyc +0 -0
  15. package/tools/hub/hub/tui/panels/__pycache__/git.cpython-314.pyc +0 -0
  16. package/tools/hub/hub/tui/panels/__pycache__/projects.cpython-314.pyc +0 -0
  17. package/tools/hub/hub/tui/panels/__pycache__/tasks.cpython-314.pyc +0 -0
  18. package/tools/hub/hub/tui/panels/projects.py +52 -2
  19. package/tools/hub/hub/tui/panels/tasks.py +2 -2
  20. package/tools/hub/hub.sh +12 -0
  21. package/tools/hub/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  22. package/tools/hub/tests/__pycache__/test_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  23. package/tools/hub/tests/__pycache__/test_db.cpython-314-pytest-9.0.2.pyc +0 -0
  24. package/tools/hub/tests/__pycache__/test_migrate.cpython-314-pytest-9.0.2.pyc +0 -0
  25. package/tools/hub/tests/__pycache__/test_projects.cpython-314-pytest-9.0.2.pyc +0 -0
  26. package/tools/hub/tests/__pycache__/test_scan.cpython-314-pytest-9.0.2.pyc +0 -0
  27. package/tools/hub/tests/__pycache__/test_tasks.cpython-314-pytest-9.0.2.pyc +0 -0
  28. package/tools/hub/tests/__pycache__/test_tui_app.cpython-314-pytest-9.0.2.pyc +0 -0
  29. package/tools/hub/tests/__pycache__/test_tui_git.cpython-314-pytest-9.0.2.pyc +0 -0
  30. package/tools/hub/tests/__pycache__/test_tui_git_panel.cpython-314-pytest-9.0.2.pyc +0 -0
  31. package/tools/hub/tests/__pycache__/test_tui_projects_panel.cpython-314-pytest-9.0.2.pyc +0 -0
  32. package/tools/hub/tests/__pycache__/test_tui_tasks_panel.cpython-314-pytest-9.0.2.pyc +0 -0
  33. package/tools/hub/tests/test_scan.py +89 -0
  34. package/tools/hub/tests/test_tui_app.py +1 -1
  35. package/tools/hub/tests/test_tui_projects_panel.py +62 -1
  36. package/tools/hub/tests/test_tui_tasks_panel.py +5 -5
  37. 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.1",
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 + npm-release)"
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/npm-release", "bundle": "meta" },
53
- { "path": "meta/opencode-runner", "bundle": "meta" }
52
+ { "path": "meta/opencode-runner", "bundle": "meta" },
53
+ { "path": "meta/project-release", "bundle": "meta" }
54
54
  ],
55
55
  "hooks": [
56
56
  {
@@ -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")
@@ -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}
@@ -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:
@@ -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("D", "delete_task_action", "Delete", show=True),
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
 
@@ -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("D")
129
+ await pilot.press("ctrl+d")
130
130
  await pilot.pause()
131
131
  assert len(list_tasks(db, project="proj")) == 1
132
- # second D — should delete
133
- await pilot.press("D")
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),告知用户确认后继续。