harveyz-skill 0.2.1 → 0.4.0
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/README.md +94 -26
- package/bin/cli.js +83 -14
- package/lib/bundles.js +16 -0
- package/package.json +4 -3
- package/skills/harness/git-workflow-init/SKILL.md +321 -0
- package/skills/harness/git-workflow-init/evals/evals.json +104 -0
- package/skills/harness/git-workflow-init/references/git-workflow-template.md +94 -0
- package/skills/harness/git-workflow-init/references/hook-templates.md +232 -0
- package/skills/harness/git-workflow-init/references/lock-file-format.md +34 -0
- package/skills/harness/git-workflow-init/references/render_docs.py +261 -0
- package/skills/harness/git-workflow-init/references/workflow-config.yml +99 -0
- package/skills-index.json +2 -1
- package/tools/p-launch/p-launch.sh +32 -7
- package/tools/p-launch/tests/unit.bats +78 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
git-workflow-init Step 7 渲染器
|
|
4
|
+
从 workflow-config.yml 动态生成 docs/reference/git-workflow.md
|
|
5
|
+
"""
|
|
6
|
+
import sys, re, os
|
|
7
|
+
|
|
8
|
+
def load_config(path):
|
|
9
|
+
"""简单 YAML 解析(只处理此 skill 使用的字段,无需完整 YAML 库)"""
|
|
10
|
+
content = open(path).read()
|
|
11
|
+
|
|
12
|
+
def extract_list_under(key, text):
|
|
13
|
+
"""提取某个 key 下的列表项(- value 或 - "value")"""
|
|
14
|
+
pattern = rf'{re.escape(key)}:\s*\n((?:[ \t]+- .+\n?)+)'
|
|
15
|
+
m = re.search(pattern, text)
|
|
16
|
+
if not m:
|
|
17
|
+
# 尝试行内列表 [a, b, c]
|
|
18
|
+
m2 = re.search(rf'{re.escape(key)}:\s*\[([^\]]+)\]', text)
|
|
19
|
+
if m2:
|
|
20
|
+
return [v.strip().strip('"') for v in m2.group(1).split(',')]
|
|
21
|
+
return []
|
|
22
|
+
return [re.sub(r'^[ \t]+- ', '', l).strip().strip('"')
|
|
23
|
+
for l in m.group(1).splitlines() if l.strip().startswith('- ')]
|
|
24
|
+
|
|
25
|
+
# 提取 branches.protected
|
|
26
|
+
protected = []
|
|
27
|
+
in_protected = False
|
|
28
|
+
current = None
|
|
29
|
+
for line in content.splitlines():
|
|
30
|
+
if re.match(r'\s+protected:', line):
|
|
31
|
+
in_protected = True
|
|
32
|
+
continue
|
|
33
|
+
if in_protected:
|
|
34
|
+
m = re.match(r'\s{4}- name:\s+(\S+)', line)
|
|
35
|
+
if m:
|
|
36
|
+
if current:
|
|
37
|
+
protected.append(current)
|
|
38
|
+
current = {'name': m.group(1), 'allow_direct_commit': False, 'merge_from': []}
|
|
39
|
+
elif current and re.match(r'\s+allow_direct_commit:\s*(true|false)', line):
|
|
40
|
+
current['allow_direct_commit'] = 'true' in line
|
|
41
|
+
elif current and re.match(r'\s+merge_from:', line):
|
|
42
|
+
pass # next lines are the list
|
|
43
|
+
elif current and re.match(r'\s{8}- ', line):
|
|
44
|
+
val = line.strip().lstrip('- ').strip('"')
|
|
45
|
+
current['merge_from'].append(val)
|
|
46
|
+
elif line.strip() and not line.startswith(' '):
|
|
47
|
+
in_protected = False
|
|
48
|
+
if current:
|
|
49
|
+
protected.append(current)
|
|
50
|
+
|
|
51
|
+
# branch_naming
|
|
52
|
+
naming_patterns = extract_list_under('allowed_patterns', content)
|
|
53
|
+
naming_exempt = extract_list_under('exempt', content)
|
|
54
|
+
|
|
55
|
+
# commit_message
|
|
56
|
+
fmt_m = re.search(r'format:\s*(\S+)', content)
|
|
57
|
+
fmt = fmt_m.group(1) if fmt_m else 'none'
|
|
58
|
+
types = extract_list_under('types', content)
|
|
59
|
+
pattern_m = re.search(r'pattern:\s*"([^"]+)"', content)
|
|
60
|
+
regex_pattern = pattern_m.group(1) if pattern_m else ''
|
|
61
|
+
max_len_m = re.search(r'max_subject_length:\s*(\d+)', content)
|
|
62
|
+
max_len = int(max_len_m.group(1)) if max_len_m else 72
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
'protected': protected,
|
|
66
|
+
'naming_patterns': naming_patterns,
|
|
67
|
+
'naming_exempt': naming_exempt,
|
|
68
|
+
'commit_format': fmt,
|
|
69
|
+
'commit_types': types,
|
|
70
|
+
'commit_regex': regex_pattern,
|
|
71
|
+
'max_subject_length': max_len,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def branch_purpose(name):
|
|
76
|
+
mapping = {
|
|
77
|
+
'main': '生产就绪代码', 'master': '生产就绪代码',
|
|
78
|
+
'staging': '集成 / 预发布', 'develop': '开发集成',
|
|
79
|
+
}
|
|
80
|
+
if name in mapping:
|
|
81
|
+
return mapping[name]
|
|
82
|
+
if name.startswith('release'):
|
|
83
|
+
return '发版准备'
|
|
84
|
+
return '—'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def pattern_to_prefix(p):
|
|
88
|
+
"""^feature/.+ → feature/"""
|
|
89
|
+
m = re.match(r'^\^?([a-z]+)/', p)
|
|
90
|
+
return m.group(1) + '/' if m else p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pattern_purpose(prefix):
|
|
94
|
+
mapping = {
|
|
95
|
+
'feature/': ('新功能', 'feature/user-auth、feature/dark-mode'),
|
|
96
|
+
'fix/': ('Bug 修复', 'fix/login-crash、fix/null-pointer'),
|
|
97
|
+
'chore/': ('维护、依赖升级等', 'chore/upgrade-deps、chore/ci-timeout'),
|
|
98
|
+
'doc/': ('文档更新', 'doc/api-reference、doc/onboarding'),
|
|
99
|
+
'docs/': ('文档更新', 'docs/api-reference、docs/onboarding'),
|
|
100
|
+
'release/': ('发版切点', 'release/1.2.0'),
|
|
101
|
+
'hotfix/': ('紧急热修复', 'hotfix/login-crash'),
|
|
102
|
+
}
|
|
103
|
+
return mapping.get(prefix, ('—', f'{prefix}example'))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render(config, template):
|
|
107
|
+
c = config
|
|
108
|
+
protected = c['protected']
|
|
109
|
+
|
|
110
|
+
# ── BRANCH_TOPOLOGY_ASCII ──────────────────────────────────────
|
|
111
|
+
# 找主分支(没有其他受保护分支 merge_from 到它的最顶层)
|
|
112
|
+
all_names = {b['name'] for b in protected}
|
|
113
|
+
merge_targets = set()
|
|
114
|
+
for b in protected:
|
|
115
|
+
for mf in b['merge_from']:
|
|
116
|
+
# mf 可能是 glob,提取固定前缀
|
|
117
|
+
base = mf.rstrip('/*')
|
|
118
|
+
if base in all_names:
|
|
119
|
+
merge_targets.add(base)
|
|
120
|
+
|
|
121
|
+
# 拓扑:逐级展开
|
|
122
|
+
def build_tree(root, depth=0):
|
|
123
|
+
b = next((x for x in protected if x['name'] == root), None)
|
|
124
|
+
if not b:
|
|
125
|
+
return ''
|
|
126
|
+
indent = ' ' * depth
|
|
127
|
+
lines = [indent + root]
|
|
128
|
+
for src in b['merge_from']:
|
|
129
|
+
src_base = src.rstrip('/*')
|
|
130
|
+
if src_base in all_names:
|
|
131
|
+
lines.append(build_tree(src_base, depth + 1))
|
|
132
|
+
else:
|
|
133
|
+
lines.append(' ' * (depth + 1) + f'<- {src}')
|
|
134
|
+
return '\n'.join(l for l in lines if l)
|
|
135
|
+
|
|
136
|
+
roots = [b['name'] for b in protected if b['name'] not in merge_targets]
|
|
137
|
+
ascii_lines = []
|
|
138
|
+
for r in roots:
|
|
139
|
+
for src in next(x for x in protected if x['name'] == r)['merge_from']:
|
|
140
|
+
src_base = src.rstrip('/*')
|
|
141
|
+
if src_base in all_names:
|
|
142
|
+
sub = next((x for x in protected if x['name'] == src_base), None)
|
|
143
|
+
ascii_lines.append(f'{r}')
|
|
144
|
+
ascii_lines.append(f' <- {src_base}')
|
|
145
|
+
for subsrc in (sub['merge_from'] if sub else []):
|
|
146
|
+
ascii_lines.append(f' <- {subsrc}')
|
|
147
|
+
else:
|
|
148
|
+
ascii_lines.append(f'{r}')
|
|
149
|
+
ascii_lines.append(f' <- {src}')
|
|
150
|
+
topology_ascii = '\n'.join(ascii_lines) if ascii_lines else ' <- '.join(b['name'] for b in protected)
|
|
151
|
+
|
|
152
|
+
# ── BRANCH_TABLE ───────────────────────────────────────────────
|
|
153
|
+
rows = ['| 分支 | 用途 | 合并目标 |', '|------|------|---------|']
|
|
154
|
+
for b in protected:
|
|
155
|
+
target = roots[0] if b['name'] != roots[0] else '—'
|
|
156
|
+
rows.append(f'| `{b["name"]}` | {branch_purpose(b["name"])} | `{target}` |')
|
|
157
|
+
for p in c['naming_patterns']:
|
|
158
|
+
prefix = pattern_to_prefix(p)
|
|
159
|
+
purpose, _ = pattern_purpose(prefix)
|
|
160
|
+
target_branch = next((b['name'] for b in protected if b['name'] not in roots), roots[0] if roots else '—')
|
|
161
|
+
rows.append(f'| `{prefix}<名称>` | {purpose} | `{target_branch}` |')
|
|
162
|
+
branch_table = '\n'.join(rows)
|
|
163
|
+
|
|
164
|
+
# ── PROTECTION_RULES ──────────────────────────────────────────
|
|
165
|
+
rule_lines = []
|
|
166
|
+
for b in protected:
|
|
167
|
+
sources = '、'.join(f'`{s}`' for s in b['merge_from'])
|
|
168
|
+
if b['allow_direct_commit']:
|
|
169
|
+
rule_lines.append(f'- **`{b["name"]}`** — 直接提交时发出警告(不阻断)。合并来源建议为 {sources}。')
|
|
170
|
+
else:
|
|
171
|
+
rule_lines.append(f'- **`{b["name"]}`** — 禁止直接提交。只接受来自 {sources} 的合并。')
|
|
172
|
+
protection_rules = '\n'.join(rule_lines)
|
|
173
|
+
|
|
174
|
+
# ── WORKFLOW EXAMPLES ─────────────────────────────────────────
|
|
175
|
+
integration = next((b['name'] for b in protected if b['name'] not in roots), roots[0] if roots else 'staging')
|
|
176
|
+
main_branch = roots[0] if roots else 'main'
|
|
177
|
+
checkout_ex = f'git checkout {integration}'
|
|
178
|
+
merge_ex = f'git checkout {integration}\ngit merge feature/my-feature'
|
|
179
|
+
release_ex = f'git checkout {main_branch}\ngit merge {integration} # 或:git merge release/x.y.z\ngit push'
|
|
180
|
+
|
|
181
|
+
# ── NAMING_TABLE ──────────────────────────────────────────────
|
|
182
|
+
naming_rows = ['| 前缀 | 适用场景 | 示例 |', '|------|---------|------|']
|
|
183
|
+
for p in c['naming_patterns']:
|
|
184
|
+
prefix = pattern_to_prefix(p)
|
|
185
|
+
purpose, example = pattern_purpose(prefix)
|
|
186
|
+
naming_rows.append(f'| `{prefix}` | {purpose} | `{example}` |')
|
|
187
|
+
naming_table = '\n'.join(naming_rows)
|
|
188
|
+
|
|
189
|
+
# ── NAMING_EXEMPT ─────────────────────────────────────────────
|
|
190
|
+
naming_exempt = '、'.join(f'`{e}`' for e in c['naming_exempt']) or '无'
|
|
191
|
+
|
|
192
|
+
# ── COMMIT_FORMAT_SECTION ─────────────────────────────────────
|
|
193
|
+
if c['commit_format'] == 'conventional':
|
|
194
|
+
types_line = ' | '.join(f'`{t}`' for t in c['commit_types'])
|
|
195
|
+
commit_section = f"""遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
<类型>(<范围>): <简短描述>
|
|
199
|
+
|
|
200
|
+
[可选正文]
|
|
201
|
+
|
|
202
|
+
[可选 footer]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**类型:** {types_line}
|
|
206
|
+
|
|
207
|
+
**首行长度限制:** {c['max_subject_length']} 个字符"""
|
|
208
|
+
elif c['commit_format'] == 'regex':
|
|
209
|
+
commit_section = f"""提交信息首行必须匹配正则:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
{c['commit_regex']}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**首行长度限制:** {c['max_subject_length']} 个字符"""
|
|
216
|
+
else:
|
|
217
|
+
commit_section = '本项目无提交信息格式要求。'
|
|
218
|
+
|
|
219
|
+
# ── FAQ_SECTION ───────────────────────────────────────────────
|
|
220
|
+
faq = []
|
|
221
|
+
if integration != main_branch:
|
|
222
|
+
faq.append(f'**Q:在 {integration} 上提交被拒绝怎么办?**\n'
|
|
223
|
+
f'A:你正在直接向 {integration} 提交。请新建分支,在新分支提交后再合并回 {integration}。')
|
|
224
|
+
release_p = [p for p in c['naming_patterns'] if 'release' in p]
|
|
225
|
+
if release_p:
|
|
226
|
+
faq.append(f'**Q:需要紧急热修复直接上 {main_branch} 怎么办?**\n'
|
|
227
|
+
f'A:从 {main_branch} 切一个 `release/*` 分支,修复后合并到 {main_branch},再反向合并到 {integration}。')
|
|
228
|
+
faq_section = '\n\n'.join(faq)
|
|
229
|
+
|
|
230
|
+
# ── 替换占位符 ─────────────────────────────────────────────────
|
|
231
|
+
result = template
|
|
232
|
+
result = result.replace('{{BRANCH_TOPOLOGY_ASCII}}', topology_ascii)
|
|
233
|
+
result = result.replace('{{BRANCH_TABLE}}', branch_table)
|
|
234
|
+
result = result.replace('{{PROTECTION_RULES}}', protection_rules)
|
|
235
|
+
result = result.replace('{{WORKFLOW_CHECKOUT_EXAMPLE}}', checkout_ex)
|
|
236
|
+
result = result.replace('{{WORKFLOW_MERGE_EXAMPLE}}', merge_ex)
|
|
237
|
+
result = result.replace('{{WORKFLOW_RELEASE_EXAMPLE}}', release_ex)
|
|
238
|
+
result = result.replace('{{NAMING_TABLE}}', naming_table)
|
|
239
|
+
result = result.replace('{{NAMING_EXEMPT}}', naming_exempt)
|
|
240
|
+
result = result.replace('{{COMMIT_FORMAT_SECTION}}', commit_section)
|
|
241
|
+
result = result.replace('{{FAQ_SECTION}}', faq_section + '\n\n' if faq_section else '')
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == '__main__':
|
|
246
|
+
config_path = sys.argv[1]
|
|
247
|
+
template_path = sys.argv[2]
|
|
248
|
+
output_path = sys.argv[3]
|
|
249
|
+
|
|
250
|
+
config = load_config(config_path)
|
|
251
|
+
template = open(template_path).read()
|
|
252
|
+
rendered = render(config, template)
|
|
253
|
+
|
|
254
|
+
# 差量写入
|
|
255
|
+
existing = open(output_path).read() if os.path.exists(output_path) else None
|
|
256
|
+
if existing == rendered:
|
|
257
|
+
print('UNCHANGED')
|
|
258
|
+
else:
|
|
259
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
260
|
+
open(output_path, 'w').write(rendered)
|
|
261
|
+
print('UPDATED' if existing else 'NEW')
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# workflow-config.yml
|
|
2
|
+
# Git 工作流配置文件 — 由 /git-workflow-init skill 读取、审核并部署
|
|
3
|
+
#
|
|
4
|
+
# 使用方式:将此文件复制到项目根目录,按需修改后运行 /git-workflow-init
|
|
5
|
+
#
|
|
6
|
+
# 每个节对应一个 git hook,注释掉整节 = 不安装该 hook
|
|
7
|
+
|
|
8
|
+
# ── 元信息 ────────────────────────────────────────────────────────────────────
|
|
9
|
+
meta:
|
|
10
|
+
name: "my-project-workflow"
|
|
11
|
+
version: "1.0.0"
|
|
12
|
+
# preset 决定各节的默认值,可在下方局部覆盖
|
|
13
|
+
# 可选值:gitflow | github-flow | trunk-based | custom
|
|
14
|
+
preset: gitflow
|
|
15
|
+
|
|
16
|
+
# ── 分支拓扑与保护(生成 pre-commit hook)────────────────────────────────────
|
|
17
|
+
#
|
|
18
|
+
# allow_direct_commit: false → 直接提交时 exit 1(硬拦截)
|
|
19
|
+
# allow_direct_commit: true → 直接提交时仅打印警告,不阻断
|
|
20
|
+
# merge_from 使用 shell glob 语法(* 匹配任意字符,不含 /)
|
|
21
|
+
branches:
|
|
22
|
+
protected:
|
|
23
|
+
- name: main
|
|
24
|
+
allow_direct_commit: false
|
|
25
|
+
merge_from:
|
|
26
|
+
- staging
|
|
27
|
+
- "release/*"
|
|
28
|
+
|
|
29
|
+
- name: staging
|
|
30
|
+
allow_direct_commit: false
|
|
31
|
+
merge_from:
|
|
32
|
+
- "feature/*"
|
|
33
|
+
- "fix/*"
|
|
34
|
+
- "chore/*"
|
|
35
|
+
- "doc/*"
|
|
36
|
+
|
|
37
|
+
# ── 分支命名规范(生成 post-checkout hook)───────────────────────────────────
|
|
38
|
+
#
|
|
39
|
+
# enforce: true → 切换到不合规分支时打印警告(advisory,不阻断)
|
|
40
|
+
# enforce: false → 跳过,不安装此 hook
|
|
41
|
+
# allowed_patterns 使用 POSIX ERE 语法
|
|
42
|
+
branch_naming:
|
|
43
|
+
enforce: true
|
|
44
|
+
allowed_patterns:
|
|
45
|
+
- "^feature/.+"
|
|
46
|
+
- "^fix/.+"
|
|
47
|
+
- "^chore/.+"
|
|
48
|
+
- "^doc/.+"
|
|
49
|
+
- "^release/[0-9]+\\.[0-9]+\\.[0-9]+(-.+)?$"
|
|
50
|
+
exempt: # 永远豁免命名检查的分支
|
|
51
|
+
- main
|
|
52
|
+
- staging
|
|
53
|
+
|
|
54
|
+
# ── 提交信息格式(生成 commit-msg hook)──────────────────────────────────────
|
|
55
|
+
#
|
|
56
|
+
# format 可选值:
|
|
57
|
+
# conventional → 使用下方 conventional 节的设置
|
|
58
|
+
# regex → 使用自定义 pattern 字段(需同时填写 pattern)
|
|
59
|
+
# none → 不检查格式(但若 enforce: true 会安装空 hook)
|
|
60
|
+
commit_message:
|
|
61
|
+
enforce: true
|
|
62
|
+
format: conventional
|
|
63
|
+
# format: regex 时启用此行:
|
|
64
|
+
# pattern: "^(JIRA-\\d+|NOJIRA): .{1,80}"
|
|
65
|
+
conventional:
|
|
66
|
+
types:
|
|
67
|
+
- feat
|
|
68
|
+
- fix
|
|
69
|
+
- chore
|
|
70
|
+
- docs
|
|
71
|
+
- refactor
|
|
72
|
+
- test
|
|
73
|
+
- style
|
|
74
|
+
- perf
|
|
75
|
+
require_scope: false # true = feat(scope): msg 中 scope 必填
|
|
76
|
+
max_subject_length: 80
|
|
77
|
+
|
|
78
|
+
# ── Tag 命名规范(生成 pre-push hook 的 tag 段)───────────────────────────────
|
|
79
|
+
#
|
|
80
|
+
# push_guard: true → 推送不合规 tag 时 exit 1
|
|
81
|
+
# allowed_patterns 使用 POSIX ERE 语法,满足其中一个即通过
|
|
82
|
+
# require_annotated: true → 强制使用 git tag -a,拒绝 lightweight tag
|
|
83
|
+
tags:
|
|
84
|
+
enforce: true
|
|
85
|
+
push_guard: true
|
|
86
|
+
allowed_patterns:
|
|
87
|
+
- "^v[0-9]+\\.[0-9]+\\.[0-9]+$" # 正式版:v1.2.3
|
|
88
|
+
- "^v[0-9]+\\.[0-9]+\\.[0-9]+-.+$" # 预发布:v1.2.3-beta.1、v1.2.3-rc.2
|
|
89
|
+
require_annotated: true
|
|
90
|
+
|
|
91
|
+
# ── 推送限制(生成 pre-push hook 的分支段)───────────────────────────────────
|
|
92
|
+
#
|
|
93
|
+
# block_force_push 列出的分支,force push 时 exit 1
|
|
94
|
+
# enforce: false 时整节跳过,不安装 pre-push hook(除非 tags 节已启用)
|
|
95
|
+
push_rules:
|
|
96
|
+
enforce: true
|
|
97
|
+
block_force_push:
|
|
98
|
+
- main
|
|
99
|
+
- staging
|
package/skills-index.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"bundleMeta": {
|
|
9
9
|
"analysis": "分析工具(skill-analyzer)",
|
|
10
10
|
"brainstorming": "设计与规划工具(brainstorming + writing-plans)",
|
|
11
|
-
"dev": "开发工作流(executing-plans + systematic-debugging + using-git-worktrees)",
|
|
11
|
+
"dev": "开发工作流(executing-plans + systematic-debugging + using-git-worktrees + git-workflow-init)",
|
|
12
12
|
"document": "文档工具(diataxis-docs)",
|
|
13
13
|
"harness": "测试工具(full-stack-debug-env)",
|
|
14
14
|
"task": "任务管理(pm-task-dispatch + task-close)",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
{ "path": "harness/diataxis-docs", "bundle": "document" },
|
|
21
21
|
{ "path": "harness/full-stack-debug-env", "bundle": "harness" },
|
|
22
22
|
{ "path": "superpowers-fork/brainstorming", "bundle": "brainstorming" },
|
|
23
|
+
{ "path": "harness/git-workflow-init", "bundle": "dev" },
|
|
23
24
|
{ "path": "superpowers-fork/executing-plans", "bundle": "dev" },
|
|
24
25
|
{ "path": "superpowers-fork/systematic-debugging", "bundle": "dev" },
|
|
25
26
|
{ "path": "superpowers-fork/using-git-worktrees", "bundle": "dev" },
|
|
@@ -68,18 +68,43 @@ _launch() {
|
|
|
68
68
|
local path="$1"
|
|
69
69
|
local name="${path:t}"
|
|
70
70
|
local display="${path/$HOME/~}"
|
|
71
|
-
local cursor_ok=false ghostty_ok=false
|
|
71
|
+
local cursor_ok=false ghostty_ok=false ghostty_err="not installed"
|
|
72
72
|
|
|
73
|
-
# Cursor IDE
|
|
73
|
+
# Cursor IDE — CLI first, fall back to .app
|
|
74
74
|
if command -v cursor &>/dev/null; then
|
|
75
75
|
cursor "$path" &>/dev/null &
|
|
76
76
|
cursor_ok=true
|
|
77
|
+
elif [[ -d "/Applications/Cursor.app" ]]; then
|
|
78
|
+
/usr/bin/open -na "Cursor" --args "$path" && cursor_ok=true
|
|
77
79
|
fi
|
|
78
80
|
|
|
79
|
-
# Ghostty —
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
# Ghostty — find app via Spotlight (handles /Applications, ~/Applications,
|
|
82
|
+
# or any custom install path), then invoke "New Ghostty Window Here" service
|
|
83
|
+
# via NSPerformService — same code path as Finder right-click service.
|
|
84
|
+
local ghostty_app
|
|
85
|
+
ghostty_app=$(mdfind "kMDItemCFBundleIdentifier == 'com.mitchellh.ghostty'" 2>/dev/null | /usr/bin/head -1)
|
|
86
|
+
if [[ -z "$ghostty_app" ]]; then
|
|
87
|
+
# mdfind can miss apps before first Spotlight index; fall back to known paths
|
|
88
|
+
for _p in "/Applications/Ghostty.app" "$HOME/Applications/Ghostty.app"; do
|
|
89
|
+
[[ -d "$_p" ]] && { ghostty_app="$_p"; break }
|
|
90
|
+
done
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
if [[ -n "$ghostty_app" ]]; then
|
|
94
|
+
ghostty_err="failed to open"
|
|
95
|
+
# Ghostty's service handler does dirname on the path, so pass a child of
|
|
96
|
+
# the target dir — dirname(target/child) == target.
|
|
97
|
+
local _child service_path
|
|
98
|
+
_child=$(/bin/ls -1A "$path" 2>/dev/null | /usr/bin/head -1)
|
|
99
|
+
service_path="${path}/${_child}"
|
|
100
|
+
${_OSASCRIPT:-/usr/bin/osascript} 2>/dev/null <<OSASCRIPT && ghostty_ok=true
|
|
101
|
+
use framework "AppKit"
|
|
102
|
+
use scripting additions
|
|
103
|
+
set thePboard to current application's NSPasteboard's generalPasteboard()
|
|
104
|
+
thePboard's clearContents()
|
|
105
|
+
thePboard's setPropertyList:{"${service_path}"} forType:"NSFilenamesPboardType"
|
|
106
|
+
return current application's NSPerformService("New Ghostty Window Here", thePboard)
|
|
107
|
+
OSASCRIPT
|
|
83
108
|
fi
|
|
84
109
|
|
|
85
110
|
# ── Launch Report ──────────────────────────────────────────────────────────
|
|
@@ -96,7 +121,7 @@ _launch() {
|
|
|
96
121
|
if $ghostty_ok; then
|
|
97
122
|
printf " ${C[gr]}✓${C[rs]} Ghostty new window opened\n"
|
|
98
123
|
else
|
|
99
|
-
printf " ${C[yl]}⚠${C[rs]} Ghostty
|
|
124
|
+
printf " ${C[yl]}⚠${C[rs]} Ghostty %s\n" "$ghostty_err"
|
|
100
125
|
fi
|
|
101
126
|
|
|
102
127
|
printf '\n'
|
|
@@ -6,7 +6,8 @@ setup() {
|
|
|
6
6
|
SCRIPT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)/p-launch.sh"
|
|
7
7
|
TEST_DIR="$(mktemp -d)"
|
|
8
8
|
MOCK_HOME="${TEST_DIR}/home"
|
|
9
|
-
|
|
9
|
+
MOCK_BIN="${TEST_DIR}/bin"
|
|
10
|
+
mkdir -p "${MOCK_HOME}" "${MOCK_BIN}"
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
teardown() {
|
|
@@ -149,3 +150,79 @@ _src() {
|
|
|
149
150
|
[ "$status" -eq 0 ]
|
|
150
151
|
[ "${#lines[@]}" -eq 2 ]
|
|
151
152
|
}
|
|
153
|
+
|
|
154
|
+
# ── _launch: Cursor ────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
_launch_src() {
|
|
157
|
+
local code="$1"
|
|
158
|
+
# _OSASCRIPT lets tests inject a mock without relying on PATH,
|
|
159
|
+
# since p-launch.sh calls /usr/bin/osascript via full path.
|
|
160
|
+
local mock_osascript="${MOCK_BIN}/osascript"
|
|
161
|
+
local osascript_override=""
|
|
162
|
+
[[ -x "$mock_osascript" ]] && osascript_override="export _OSASCRIPT='${mock_osascript}'"
|
|
163
|
+
zsh -c "
|
|
164
|
+
export _P_LAUNCH_TEST=1
|
|
165
|
+
export HOME='${MOCK_HOME}'
|
|
166
|
+
export PATH='${MOCK_BIN}:${PATH}'
|
|
167
|
+
${osascript_override}
|
|
168
|
+
source '${SCRIPT}'
|
|
169
|
+
${code}
|
|
170
|
+
"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@test "_launch: reports ✓ Cursor when cursor CLI is in PATH" {
|
|
174
|
+
printf '#!/bin/sh\nexit 0\n' > "${MOCK_BIN}/cursor"
|
|
175
|
+
chmod +x "${MOCK_BIN}/cursor"
|
|
176
|
+
|
|
177
|
+
run _launch_src "_launch '${TEST_DIR}'"
|
|
178
|
+
[ "$status" -eq 0 ]
|
|
179
|
+
[[ "$output" == *"✓"*"Cursor"* ]]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@test "_launch: reports ⚠ Cursor when CLI absent and Cursor.app not found" {
|
|
183
|
+
[[ ! -d "/Applications/Cursor.app" ]] || skip "Cursor.app installed — cannot test absence"
|
|
184
|
+
|
|
185
|
+
run _launch_src "_launch '${TEST_DIR}'"
|
|
186
|
+
[ "$status" -eq 0 ]
|
|
187
|
+
[[ "$output" == *"⚠"*"Cursor"* ]]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# ── _launch: Ghostty ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
@test "_launch: reports ✓ Ghostty when osascript succeeds" {
|
|
193
|
+
[[ -d "/Applications/Ghostty.app" ]] || skip "Ghostty.app not installed"
|
|
194
|
+
printf '#!/bin/sh\nexit 0\n' > "${MOCK_BIN}/osascript"
|
|
195
|
+
chmod +x "${MOCK_BIN}/osascript"
|
|
196
|
+
|
|
197
|
+
run _launch_src "_launch '${TEST_DIR}'"
|
|
198
|
+
[ "$status" -eq 0 ]
|
|
199
|
+
[[ "$output" == *"✓"*"Ghostty"* ]]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@test "_launch: reports ⚠ Ghostty when osascript fails" {
|
|
203
|
+
[[ -d "/Applications/Ghostty.app" ]] || skip "Ghostty.app not installed"
|
|
204
|
+
printf '#!/bin/sh\nexit 1\n' > "${MOCK_BIN}/osascript"
|
|
205
|
+
chmod +x "${MOCK_BIN}/osascript"
|
|
206
|
+
|
|
207
|
+
run _launch_src "_launch '${TEST_DIR}'"
|
|
208
|
+
[ "$status" -eq 0 ]
|
|
209
|
+
[[ "$output" == *"⚠"*"Ghostty"* ]]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@test "_launch: reports ⚠ Ghostty when Ghostty.app not found" {
|
|
213
|
+
[[ ! -d "/Applications/Ghostty.app" ]] || skip "Ghostty.app installed — cannot test absence"
|
|
214
|
+
|
|
215
|
+
run _launch_src "_launch '${TEST_DIR}'"
|
|
216
|
+
[ "$status" -eq 0 ]
|
|
217
|
+
[[ "$output" == *"⚠"*"Ghostty"* ]]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@test "_launch: osascript receives working directory in script" {
|
|
221
|
+
[[ -d "/Applications/Ghostty.app" ]] || skip "Ghostty.app not installed"
|
|
222
|
+
printf '#!/bin/sh\nprintf "ARGS:%%s\n" "$@"\nexit 0\n' \
|
|
223
|
+
> "${MOCK_BIN}/osascript"
|
|
224
|
+
chmod +x "${MOCK_BIN}/osascript"
|
|
225
|
+
|
|
226
|
+
run _launch_src "_launch '${TEST_DIR}'"
|
|
227
|
+
[[ "$output" == *"${TEST_DIR}"* ]]
|
|
228
|
+
}
|