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.
@@ -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 — force a new window at the project directory
80
- if [[ -d "/Applications/Ghostty.app" ]]; then
81
- open -na "Ghostty" --args --working-directory="$path"
82
- ghostty_ok=true
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 /Applications/Ghostty.app not found\n"
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
- mkdir -p "${MOCK_HOME}"
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
+ }