This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
package cc
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// rolloutUUIDRegex matches the UUID at the tail of rollout-<ISO>-<UUID>.jsonl.
// Matches v7 UUIDs (still 8-4-4-4-12 hex groups).
var rolloutUUIDRegex = regexp.MustCompile(`-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$`)
// extractIDFromRolloutFilename tries to extract the thread_id UUID from a
// rollout filename. Returns "" when the name doesn't match the expected pattern.
func extractIDFromRolloutFilename(filename string) string {
m := rolloutUUIDRegex.FindStringSubmatch(strings.ToLower(filename))
if m == nil {
return ""
}
return m[1]
}
// CodexSessionsDir returns the codex sessions directory for a given date:
// <homeDir>/.codex/sessions/YYYY/MM/DD. The time.Time parameter makes this
// testable without calling time.Now() inside the library.
func CodexSessionsDir(homeDir string, t time.Time) string {
return filepath.Join(homeDir, ".codex", "sessions",
fmt.Sprintf("%04d", t.Year()),
fmt.Sprintf("%02d", t.Month()),
fmt.Sprintf("%02d", t.Day()),
)
}
// CodexSessionWatchOpts configures WatchForNewCodexSession.
type CodexSessionWatchOpts struct {
SessionsDir string
Timeout time.Duration
}
// WatchForNewCodexSession watches SessionsDir for the next new rollout-*.jsonl
// file. It prefers the UUID embedded in the filename; falls back to parsing
// payload.id from the first JSON line. Returns ("", nil) on timeout or context
// cancellation. MkdirAll ensures the leaf dir exists so the first-of-day
// session (when codex creates the YYYY/MM/DD dir from scratch) is captured.
//
// Known limitation: a session file landing in a different day-dir than the one
// watched (watcher started just before midnight) will not be caught.
func WatchForNewCodexSession(ctx context.Context, opts CodexSessionWatchOpts) (string, error) {
// Ensure the target dir exists so fsnotify can watch it even before codex
// creates it for the first time today.
if err := os.MkdirAll(opts.SessionsDir, 0o755); err != nil {
return "", nil
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return "", nil
}
defer watcher.Close()
if err := watcher.Add(opts.SessionsDir); err != nil {
return "", nil
}
// Deduplicate: fsnotify may fire multiple events for the same file.
seen := make(map[string]bool)
timer := time.NewTimer(opts.Timeout)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return "", nil
case <-timer.C:
return "", nil
case event, ok := <-watcher.Events:
if !ok {
return "", nil
}
if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 {
continue
}
name := filepath.Base(event.Name)
if !strings.HasPrefix(name, "rollout-") || !strings.HasSuffix(name, ".jsonl") {
continue
}
if seen[name] {
continue
}
seen[name] = true
// Confirm the file exists (rename fires for deletes too).
if _, err := os.Stat(event.Name); err != nil {
continue
}
// Prefer UUID from filename; fall back to first-line JSON payload.id.
if id := extractIDFromRolloutFilename(name); id != "" {
return id, nil
}
if id := extractIDFromFirstLine(event.Name); id != "" {
return id, nil
}
// Could not extract an id — keep watching.
case _, ok := <-watcher.Errors:
if !ok {
return "", nil
}
}
}
}
// sessionMetaLine is the shape of the first JSON line in a codex rollout file.
type sessionMetaLine struct {
Payload *struct {
ID string `json:"id"`
} `json:"payload"`
ThreadID string `json:"thread_id"`
SessionID string `json:"session_id"`
ID string `json:"id"`
}
// extractIDFromFirstLine reads the first JSON line of filePath and returns the
// thread id found in payload.id, thread_id, session_id, or id (in that order).
func extractIDFromFirstLine(filePath string) string {
f, err := os.Open(filePath)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return ""
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
return ""
}
var meta sessionMetaLine
if err := json.Unmarshal([]byte(line), &meta); err != nil {
return ""
}
if meta.Payload != nil && meta.Payload.ID != "" {
return meta.Payload.ID
}
if meta.ThreadID != "" {
return meta.ThreadID
}
if meta.SessionID != "" {
return meta.SessionID
}
return meta.ID
}
@@ -0,0 +1,245 @@
package cc
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
// --- pure UUID extraction tests ---
func TestExtractIDFromRolloutFilename(t *testing.T) {
cases := []struct {
name string
want string
}{
{
"rollout-2026-04-27T02-57-26-019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a.jsonl",
"019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a",
},
{
"rollout-2026-01-01T00-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl",
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
},
{
// No UUID — should return empty string.
"rollout-2026-04-27T02-57-26.jsonl",
"",
},
{
// Not a rollout file.
"session.jsonl",
"",
},
}
for _, tc := range cases {
got := extractIDFromRolloutFilename(tc.name)
if got != tc.want {
t.Errorf("extractIDFromRolloutFilename(%q) = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestCodexSessionsDir(t *testing.T) {
ts := time.Date(2026, 4, 27, 2, 57, 26, 0, time.UTC)
got := CodexSessionsDir("/home/alice", ts)
want := "/home/alice/.codex/sessions/2026/04/27"
if got != want {
t.Errorf("CodexSessionsDir = %q, want %q", got, want)
}
}
// --- watcher tests ---
func TestWatchForNewCodexSession_DetectsRolloutByFilename(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
SessionsDir: dir,
Timeout: 2 * time.Second,
})
ch <- struct {
id string
err error
}{id, err}
}()
time.Sleep(50 * time.Millisecond)
uuid := "019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a"
fname := "rollout-2026-04-27T02-57-26-" + uuid + ".jsonl"
f, err := os.Create(filepath.Join(dir, fname))
if err != nil {
t.Fatal(err)
}
f.Close()
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.id != uuid {
t.Errorf("got id %q, want %q", r.id, uuid)
}
}
func TestWatchForNewCodexSession_FallbackToFirstLine(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
SessionsDir: dir,
Timeout: 2 * time.Second,
})
ch <- struct {
id string
err error
}{id, err}
}()
time.Sleep(50 * time.Millisecond)
// File name without a UUID — triggers first-line fallback.
wantID := "fallback-thread-id-0001"
fname := "rollout-2026-04-27T02-57-26-no-uuid-here.jsonl"
content := `{"timestamp":"2026-04-27T02:57:26Z","type":"session_meta","payload":{"id":"` + wantID + `","cwd":"/tmp"}}` + "\n"
if err := os.WriteFile(filepath.Join(dir, fname), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.id != wantID {
t.Errorf("got id %q, want %q", r.id, wantID)
}
}
func TestWatchForNewCodexSession_TimeoutReturnsEmptyNil(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
SessionsDir: dir,
Timeout: 200 * time.Millisecond,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != "" {
t.Errorf("expected empty string on timeout, got %q", id)
}
}
// TestWatchForNewCodexSession_FirstOfDay proves the primary fix: when
// SessionsDir does not exist at call time (first codex session of the day),
// MkdirAll creates it and the watcher still captures the rollout file.
func TestWatchForNewCodexSession_FirstOfDay(t *testing.T) {
base := t.TempDir()
// SessionsDir does NOT exist yet — simulates first-of-day scenario.
sessDir := filepath.Join(base, "2099", "12", "31")
ctx := context.Background()
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
SessionsDir: sessDir,
Timeout: 300 * time.Millisecond,
})
ch <- struct {
id string
err error
}{id, err}
}()
// Wait for watcher to start and MkdirAll to create the dir.
time.Sleep(50 * time.Millisecond)
uuid := "019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a"
fname := "rollout-2099-12-31T00-00-00-" + uuid + ".jsonl"
f, err := os.Create(filepath.Join(sessDir, fname))
if err != nil {
t.Fatalf("create rollout file: %v", err)
}
f.Close()
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.id != uuid {
t.Errorf("got id %q, want %q (first-of-day session not captured)", r.id, uuid)
}
}
// TestWatchForNewCodexSession_MkdirAllFailReturnsEmptyNil verifies that when
// MkdirAll itself cannot create the dir (e.g. path under a file), the watcher
// returns ("", nil) without panicking.
func TestWatchForNewCodexSession_MkdirAllFailReturnsEmptyNil(t *testing.T) {
base := t.TempDir()
// Create a plain file so MkdirAll on a sub-path will fail.
blocker := filepath.Join(base, "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
SessionsDir: filepath.Join(blocker, "subdir"),
Timeout: 300 * time.Millisecond,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != "" {
t.Errorf("expected empty string when MkdirAll fails, got %q", id)
}
}
func TestWatchForNewCodexSession_ContextCancel(t *testing.T) {
dir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
SessionsDir: dir,
Timeout: 5 * time.Second,
})
ch <- struct {
id string
err error
}{id, err}
}()
time.Sleep(30 * time.Millisecond)
cancel()
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error on cancel: %v", r.err)
}
if r.id != "" {
t.Errorf("expected empty id on cancel, got %q", r.id)
}
}
+23
View File
@@ -0,0 +1,23 @@
package cc
import (
"errors"
"testing"
)
func TestClaudeTimeoutErrorUnwrapsToClaudeError(t *testing.T) {
err := error(&ClaudeTimeoutError{ClaudeError{Msg: "timeout", Stderr: "boom"}})
var ce *ClaudeError
if !errors.As(err, &ce) {
t.Fatal("errors.As(*ClaudeError) should match a *ClaudeTimeoutError")
}
if ce.Msg != "timeout" {
t.Errorf("unwrapped Msg = %q, want %q", ce.Msg, "timeout")
}
var te *ClaudeTimeoutError
if !errors.As(err, &te) {
t.Fatal("errors.As(*ClaudeTimeoutError) should still match")
}
}
+38
View File
@@ -0,0 +1,38 @@
package cc
import (
"os"
"path/filepath"
"sort"
"strings"
)
// ClaudeProfile represents a Claude settings profile discovered on disk.
type ClaudeProfile struct {
Name string
Path string
}
// ListClaudeProfiles scans dir for *.json files and returns them sorted by Name.
// A missing or unreadable dir returns an empty slice without error.
func ListClaudeProfiles(dir string) ([]ClaudeProfile, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return []ClaudeProfile{}, nil
}
var profiles []ClaudeProfile
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
profiles = append(profiles, ClaudeProfile{
Name: name,
Path: filepath.Join(dir, e.Name()),
})
}
sort.Slice(profiles, func(i, j int) bool {
return profiles[i].Name < profiles[j].Name
})
return profiles, nil
}
+63
View File
@@ -0,0 +1,63 @@
package cc
import (
"os"
"path/filepath"
"testing"
)
func TestListClaudeProfiles_basic(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"beta.json", "alpha.json", "gamma.json"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
// non-.json files should be ignored
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// subdirs should be ignored
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
t.Fatal(err)
}
profiles, err := ListClaudeProfiles(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(profiles) != 3 {
t.Fatalf("want 3 profiles, got %d", len(profiles))
}
wantNames := []string{"alpha", "beta", "gamma"}
for i, p := range profiles {
if p.Name != wantNames[i] {
t.Errorf("profiles[%d].Name = %q, want %q", i, p.Name, wantNames[i])
}
wantPath := filepath.Join(dir, wantNames[i]+".json")
if p.Path != wantPath {
t.Errorf("profiles[%d].Path = %q, want %q", i, p.Path, wantPath)
}
}
}
func TestListClaudeProfiles_emptyDir(t *testing.T) {
dir := t.TempDir()
profiles, err := ListClaudeProfiles(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(profiles) != 0 {
t.Fatalf("want 0 profiles, got %d", len(profiles))
}
}
func TestListClaudeProfiles_missingDir(t *testing.T) {
profiles, err := ListClaudeProfiles("/nonexistent/path/does/not/exist")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(profiles) != 0 {
t.Fatalf("want 0 profiles, got %d", len(profiles))
}
}
+52
View File
@@ -0,0 +1,52 @@
package cc
import (
_ "embed"
"strings"
"superwork-tui/internal/config"
)
//go:embed prompts/brainstorm.md
var defaultBrainstormPrompt string
//go:embed prompts/brainstorm-continue.md
var defaultBrainstormContinuePrompt string
//go:embed prompts/implement-plan.md
var defaultImplementPlanPrompt string
//go:embed prompts/review.md
var defaultReviewPrompt string
func BrainstormPrompt(s *config.Settings, vars struct{ UserRequest, Nonce string }) string {
tpl := s.BrainstormPrompt
if tpl == "" {
tpl = defaultBrainstormPrompt
}
return strings.NewReplacer("{userRequest}", vars.UserRequest, "{nonce}", vars.Nonce).Replace(tpl)
}
func BrainstormContinuePrompt(s *config.Settings, vars struct{ IssueNumber string }) string {
tpl := s.BrainstormContinuePrompt
if tpl == "" {
tpl = defaultBrainstormContinuePrompt
}
return strings.NewReplacer("{issueNumber}", vars.IssueNumber).Replace(tpl)
}
func ImplementPlanPrompt(s *config.Settings, vars struct{ PlanFile, IssueNumber string }) string {
tpl := s.ImplementPlanPrompt
if tpl == "" {
tpl = defaultImplementPlanPrompt
}
return strings.NewReplacer("{planFile}", vars.PlanFile, "{issueNumber}", vars.IssueNumber).Replace(tpl)
}
func ReviewPrompt(s *config.Settings, vars struct{ PrNumber string }) string {
tpl := s.ReviewPrompt
if tpl == "" {
tpl = defaultReviewPrompt
}
return strings.NewReplacer("{prNumber}", vars.PrNumber).Replace(tpl)
}
@@ -0,0 +1,28 @@
/superpowers:brainstorming 讨论下 {issueNumber} 号工单
用tea命令找工单
## 后续 marker 维护(本会话有效)
spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支。创建后追加对应 marker:
```
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
```
## 严禁擅自继续
如果你创建/更新了 spec/plan 文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
特别地:
- 不要创建分支
- 不要切换分支,包括 `git checkout` / `git switch`
- 不要修改当前主 worktree 所在分支
- spec/plan 一律在 main 分支(当前主 worktree)创建
- 不要修改任何代码文件
- 不要创建 PR
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
只讨论需求与 spec/plan,必要时用 spx issue marker 更新 marker。
+48
View File
@@ -0,0 +1,48 @@
接下来我要实现下面这样的功能: {userRequest}
你的任务:用 spx CLI 创建一个 Gitea 工单。spx 用法参考 `using-spx-cli` skill。
## 工单格式
先检查仓库的 `.gitea/ISSUE_TEMPLATE/` 目录:
- 有模板(`.md``.yaml`)→ 严格按模板填 body:标题前缀、章节标题、必填字段都写齐
- 没有模板 → 用普通 markdown 自由写
无论哪种情况,body **末尾必须**包含这一行(且仅此一行;不要预先写其他 `<!-- spx:* -->` marker):
```
<!-- spx:nonce={nonce} -->
```
## 创建
把 body 写到 `/tmp/issue-body.md`,调 spx
```
opencli spx issue create --title "<标题>" --body-file /tmp/issue-body.md
```
spx 返回工单号 + html_url。记下工单号,后续命令用。
## 后续 marker 维护(本会话有效)
**只有**当你真正创建了 spec 或 plan 文件后才追加对应 marker。路径形如 `docs/superpowers/specs/<slug>/spec.md` 或 `docs/superpowers/plans/<slug>/plan.md`。spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支:
```
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
```
spx 自动找到对应行替换或追加,保留所有其他 marker。**不要自己手写 `<!-- spx:* -->` 行**。
## 严禁擅自继续
成功创建工单后**立即停下**汇报:输出工单号 + html_url 即可。
特别地:
- 不要创建分支
- 不要切换分支
- 不要修改当前主 worktree 所在分支
- spec/plan 一律在 main 分支(当前主 worktree)创建
+13
View File
@@ -0,0 +1,13 @@
/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
## 数据库迁移(alembic
多个 feature worktree 共用同一台 dev DB`192.168.1.4:5433`)。任何 worktree 直接在共享库上跑迁移,都会让别的分支 `alembic upgrade` 崩(DB 里记着的 revision 在对方代码里不存在)。所以本会话:
- 只用 `uv run alembic revision --autogenerate -m "..."` 生成迁移文件,**绝不手写 revision 文件**。
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
- **绝不**对共享 dev DB`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
+18
View File
@@ -0,0 +1,18 @@
/review 审查这个仓库的 PR #{prNumber}。
`tea pulls {prNumber}` 看 PR 元信息(标题/描述/分支)。看代码差异用 git(当前目录就是 PR 分支的 worktree):先 `git fetch origin main`,再 `git diff origin/main...HEAD`(概览可加 --stat)。注意 tea 没有 `diff` 子命令,不要尝试 `tea pulls diff`
## 提交审查意见
把审查意见(markdown 格式)写到 `/tmp/review-{prNumber}.md`,调 spx
```
opencli spx pr review-comment --pr {prNumber} --body-file /tmp/review-{prNumber}.md
## 严禁
- 不要在审查意见里建议"合并 PR"或"merge"
- 不要执行 `tea pulls merge` 或任何合并命令
- 不要 push 到 main / dev 分支
合并权完全在用户手上,你的工作只是指出问题或确认通过。
+75
View File
@@ -0,0 +1,75 @@
package cc
import (
"strings"
"testing"
"superwork-tui/internal/config"
)
func TestBrainstormPrompt_SubstitutesVars(t *testing.T) {
s := config.DefaultSettings()
out := BrainstormPrompt(s, struct{ UserRequest, Nonce string }{"add dark mode", "deadbeef"})
if !strings.Contains(out, "add dark mode") {
t.Error("output should contain userRequest")
}
if !strings.Contains(out, "deadbeef") {
t.Error("output should contain nonce")
}
if strings.Contains(out, "{userRequest}") {
t.Error("placeholder {userRequest} should be replaced")
}
if strings.Contains(out, "{nonce}") {
t.Error("placeholder {nonce} should be replaced")
}
}
func TestBrainstormPrompt_SettingsOverride(t *testing.T) {
s := config.DefaultSettings()
s.BrainstormPrompt = "custom {userRequest}"
out := BrainstormPrompt(s, struct{ UserRequest, Nonce string }{"fix login", "xx"})
if out != "custom fix login" {
t.Errorf("expected 'custom fix login', got %q", out)
}
}
func TestBrainstormPrompt_DefaultFallback(t *testing.T) {
s := config.DefaultSettings()
out := BrainstormPrompt(s, struct{ UserRequest, Nonce string }{"req", "n"})
if out == "" {
t.Error("expected non-empty output from embedded default")
}
}
func TestBrainstormContinuePrompt_SubstitutesVars(t *testing.T) {
s := config.DefaultSettings()
out := BrainstormContinuePrompt(s, struct{ IssueNumber string }{"42"})
if !strings.Contains(out, "42") {
t.Error("output should contain issue number")
}
if strings.Contains(out, "{issueNumber}") {
t.Error("placeholder {issueNumber} should be replaced")
}
}
func TestImplementPlanPrompt_SubstitutesVars(t *testing.T) {
s := config.DefaultSettings()
out := ImplementPlanPrompt(s, struct{ PlanFile, IssueNumber string }{"docs/plan.md", "7"})
if !strings.Contains(out, "docs/plan.md") {
t.Error("output should contain planFile")
}
if !strings.Contains(out, "7") {
t.Error("output should contain issueNumber")
}
}
func TestReviewPrompt_SubstitutesVars(t *testing.T) {
s := config.DefaultSettings()
out := ReviewPrompt(s, struct{ PrNumber string }{"13"})
if !strings.Contains(out, "13") {
t.Error("output should contain prNumber")
}
if strings.Contains(out, "{prNumber}") {
t.Error("placeholder {prNumber} should be replaced")
}
}
+104
View File
@@ -0,0 +1,104 @@
package cc
import (
"bufio"
"bytes"
"context"
"encoding/json"
"log/slog"
"os/exec"
"regexp"
"time"
)
const reviewDefaultTimeoutMs = 300_000
// ReviewOpts configures a RunReview call.
type ReviewOpts struct {
WorkspaceRoot string
Prompt string
TimeoutMs int // 0 → reviewDefaultTimeoutMs
OnThreadID func(string)
}
// RunCodex is the injectable runner. Tests replace this to avoid needing a real codex binary.
var RunCodex func(ctx context.Context, cwd string, args []string) (stdout, stderr []byte, err error) = defaultRunCodex
func defaultRunCodex(ctx context.Context, cwd string, args []string) ([]byte, []byte, error) {
cmd := exec.CommandContext(ctx, "codex", args...)
if cwd != "" {
cmd.Dir = cwd
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.Bytes(), stderr.Bytes(), err
}
// RunReview runs `codex exec review --json` and calls opts.OnThreadID with the
// first thread_id/session_id found in the NDJSON output.
// Errors are logged, not returned (fire-and-forget semantics).
func RunReview(ctx context.Context, opts ReviewOpts) error {
timeoutMs := opts.TimeoutMs
if timeoutMs <= 0 {
timeoutMs = reviewDefaultTimeoutMs
}
tCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
defer cancel()
args := []string{
"exec",
"-c", "model_reasoning_effort=xhigh",
"review",
"--dangerously-bypass-approvals-and-sandbox",
"--json",
opts.Prompt,
}
stdout, stderr, err := RunCodex(tCtx, opts.WorkspaceRoot, args)
if err != nil {
slog.Warn("codex exec review failed", "err", err, "stderr", string(stderr))
return nil // fire-and-forget: never propagate
}
if opts.OnThreadID != nil {
scanForThreadID(stdout, opts.OnThreadID)
}
return nil
}
type threadEvent struct {
ThreadID string `json:"thread_id"`
SessionID string `json:"session_id"`
ID string `json:"id"`
}
var uuidRe = regexp.MustCompile(`(?i)^[0-9a-f-]{16,}$`)
func scanForThreadID(ndjson []byte, onThreadID func(string)) {
scanner := bufio.NewScanner(bytes.NewReader(ndjson))
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var ev threadEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
}
id := ""
switch {
case ev.ThreadID != "":
id = ev.ThreadID
case ev.SessionID != "":
id = ev.SessionID
case uuidRe.MatchString(ev.ID):
id = ev.ID
}
if id != "" {
onThreadID(id)
return
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package cc_test
import (
"context"
"fmt"
"strings"
"testing"
"superwork-tui/internal/cc"
)
func TestRunReview_CallsOnThreadID(t *testing.T) {
ndjson := strings.Join([]string{
`{"type":"thread.started","thread_id":"tid-abc-123"}`,
`{"type":"message","content":"hello"}`,
}, "\n") + "\n"
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return []byte(ndjson), nil, nil
}
var gotID string
opts := cc.ReviewOpts{
WorkspaceRoot: "/tmp",
Prompt: "review this",
OnThreadID: func(id string) { gotID = id },
}
oldRunner := cc.RunCodex
cc.RunCodex = fakeRunner
defer func() { cc.RunCodex = oldRunner }()
if err := cc.RunReview(context.Background(), opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotID != "tid-abc-123" {
t.Errorf("want tid-abc-123, got %q", gotID)
}
}
func TestRunReview_NoThreadStarted_NotCalled(t *testing.T) {
ndjson := `{"type":"message","content":"hello"}` + "\n"
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return []byte(ndjson), nil, nil
}
called := false
opts := cc.ReviewOpts{
WorkspaceRoot: "/tmp",
Prompt: "review this",
OnThreadID: func(id string) { called = true },
}
oldRunner := cc.RunCodex
cc.RunCodex = fakeRunner
defer func() { cc.RunCodex = oldRunner }()
if err := cc.RunReview(context.Background(), opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if called {
t.Error("OnThreadID should not be called when no thread.started event")
}
}
func TestRunReview_SessionIDFallback(t *testing.T) {
// older codex uses session_id
ndjson := `{"type":"session.created","session_id":"sess-xyz"}` + "\n"
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return []byte(ndjson), nil, nil
}
var gotID string
opts := cc.ReviewOpts{
WorkspaceRoot: "/tmp",
Prompt: "review",
OnThreadID: func(id string) { gotID = id },
}
oldRunner := cc.RunCodex
cc.RunCodex = fakeRunner
defer func() { cc.RunCodex = oldRunner }()
cc.RunReview(context.Background(), opts) //nolint
if gotID != "sess-xyz" {
t.Errorf("want sess-xyz, got %q", gotID)
}
}
func TestRunReview_RunnerError_NoError(t *testing.T) {
// fire-and-forget: runner error should NOT propagate
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return nil, []byte("exec error"), fmt.Errorf("exec failed")
}
opts := cc.ReviewOpts{WorkspaceRoot: "/tmp", Prompt: "x"}
oldRunner := cc.RunCodex
cc.RunCodex = fakeRunner
defer func() { cc.RunCodex = oldRunner }()
// Should not panic or return error — fire-and-forget
cc.RunReview(context.Background(), opts)
}
+110
View File
@@ -0,0 +1,110 @@
package cc
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// EncodeCwdForProjectsDir converts an absolute path to claude's projects-dir
// encoding: both '/' and '.' are replaced with '-'.
func EncodeCwdForProjectsDir(absPath string) string {
return strings.NewReplacer("/", "-", ".", "-").Replace(absPath)
}
// ClaudeProjectsDir returns the claude transcript directory for a given cwd:
// <homeDir>/.claude/projects/<encoded-cwd>.
func ClaudeProjectsDir(homeDir, cwd string) string {
return filepath.Join(homeDir, ".claude", "projects", EncodeCwdForProjectsDir(cwd))
}
// SessionWatchOpts configures WatchForNewSession.
type SessionWatchOpts struct {
ProjectsDir string
Timeout time.Duration
}
// WatchForNewSession watches ProjectsDir for the first new .jsonl file that
// appears after the call, returning its basename without the .jsonl suffix
// (the claude session id). Returns ("", nil) on timeout or context cancellation,
// mirroring the TypeScript null-on-timeout semantics. MkdirAll ensures the
// target dir exists so a missing claude projects dir is handled symmetrically
// with the codex watcher — the first session is captured even if the dir was
// absent at call time.
func WatchForNewSession(ctx context.Context, opts SessionWatchOpts) (string, error) {
// Ensure the target dir exists before snapshotting and watching.
if err := os.MkdirAll(opts.ProjectsDir, 0o755); err != nil {
return "", nil
}
// Snapshot existing .jsonl files so we only react to truly new ones.
snapshot, err := snapshotJSONL(opts.ProjectsDir)
if err != nil {
return "", nil
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return "", nil
}
defer watcher.Close()
if err := watcher.Add(opts.ProjectsDir); err != nil {
return "", nil
}
timer := time.NewTimer(opts.Timeout)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return "", nil
case <-timer.C:
return "", nil
case event, ok := <-watcher.Events:
if !ok {
return "", nil
}
if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 {
continue
}
name := filepath.Base(event.Name)
if !strings.HasSuffix(name, ".jsonl") {
continue
}
if snapshot[name] {
continue
}
// Confirm the file actually exists (rename fires for deletes too).
if _, err := os.Stat(event.Name); err != nil {
continue
}
return strings.TrimSuffix(name, ".jsonl"), nil
case _, ok := <-watcher.Errors:
if !ok {
return "", nil
}
// Non-fatal watcher error; keep watching.
}
}
}
// snapshotJSONL returns the set of .jsonl filenames currently in dir.
func snapshotJSONL(dir string) (map[string]bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
snap := make(map[string]bool, len(entries))
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") {
snap[e.Name()] = true
}
}
return snap, nil
}
+201
View File
@@ -0,0 +1,201 @@
package cc
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
// --- pure encoding tests ---
func TestEncodeCwdForProjectsDir(t *testing.T) {
cases := []struct {
cwd string
want string
}{
{"/home/user/project", "-home-user-project"},
{"/tmp/my.project", "-tmp-my-project"},
{"/a/b/c", "-a-b-c"},
{"/single", "-single"},
}
for _, tc := range cases {
got := EncodeCwdForProjectsDir(tc.cwd)
if got != tc.want {
t.Errorf("EncodeCwdForProjectsDir(%q) = %q, want %q", tc.cwd, got, tc.want)
}
}
}
func TestClaudeProjectsDir(t *testing.T) {
got := ClaudeProjectsDir("/home/alice", "/home/alice/myrepo")
want := "/home/alice/.claude/projects/-home-alice-myrepo"
if got != want {
t.Errorf("ClaudeProjectsDir = %q, want %q", got, want)
}
}
// --- watcher tests ---
func TestWatchForNewSession_DetectsNewFile(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
type result struct {
id string
err error
}
ch := make(chan result, 1)
go func() {
id, err := WatchForNewSession(ctx, SessionWatchOpts{
ProjectsDir: dir,
Timeout: 2 * time.Second,
})
ch <- result{id, err}
}()
// Give the watcher time to start.
time.Sleep(50 * time.Millisecond)
// Create a new .jsonl file — this should resolve the watcher.
sessionID := "abc123sessionid"
f, err := os.Create(filepath.Join(dir, sessionID+".jsonl"))
if err != nil {
t.Fatal(err)
}
f.Close()
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.id != sessionID {
t.Errorf("got id %q, want %q", r.id, sessionID)
}
}
func TestWatchForNewSession_IgnoresExistingFile(t *testing.T) {
dir := t.TempDir()
// Pre-existing file must be ignored.
existing := "existing-session"
f, _ := os.Create(filepath.Join(dir, existing+".jsonl"))
f.Close()
ctx := context.Background()
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewSession(ctx, SessionWatchOpts{
ProjectsDir: dir,
Timeout: 200 * time.Millisecond,
})
ch <- struct {
id string
err error
}{id, err}
}()
// No new file created — should timeout returning ("", nil).
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.id != "" {
t.Errorf("expected empty id on timeout, got %q", r.id)
}
}
func TestWatchForNewSession_TimeoutReturnsEmptyNil(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
id, err := WatchForNewSession(ctx, SessionWatchOpts{
ProjectsDir: dir,
Timeout: 200 * time.Millisecond,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != "" {
t.Errorf("expected empty string on timeout, got %q", id)
}
}
// TestWatchForNewSession_MissingDirThenCreated proves the symmetric fix for the
// claude watcher: ProjectsDir absent at call time is created by MkdirAll, and a
// new session file written afterwards is still captured.
func TestWatchForNewSession_MissingDirThenCreated(t *testing.T) {
base := t.TempDir()
// ProjectsDir does NOT exist yet.
projDir := filepath.Join(base, "projects", "-home-user-myrepo")
ctx := context.Background()
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewSession(ctx, SessionWatchOpts{
ProjectsDir: projDir,
Timeout: 300 * time.Millisecond,
})
ch <- struct {
id string
err error
}{id, err}
}()
// Allow MkdirAll + watcher.Add to complete.
time.Sleep(50 * time.Millisecond)
sessionID := "newclaudesessionabc"
f, err := os.Create(filepath.Join(projDir, sessionID+".jsonl"))
if err != nil {
t.Fatalf("create session file: %v", err)
}
f.Close()
r := <-ch
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if r.id != sessionID {
t.Errorf("got id %q, want %q (missing-dir session not captured)", r.id, sessionID)
}
}
func TestWatchForNewSession_ContextCancel(t *testing.T) {
dir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan struct {
id string
err error
}, 1)
go func() {
id, err := WatchForNewSession(ctx, SessionWatchOpts{
ProjectsDir: dir,
Timeout: 5 * time.Second,
})
ch <- struct {
id string
err error
}{id, err}
}()
time.Sleep(30 * time.Millisecond)
cancel()
r := <-ch
// Context cancellation returns ("", nil) matching timeout semantics.
if r.err != nil {
t.Fatalf("unexpected error on cancel: %v", r.err)
}
if r.id != "" {
t.Errorf("expected empty id on cancel, got %q", r.id)
}
}
+181
View File
@@ -0,0 +1,181 @@
package cc
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Image input (stream-json path) is not implemented; see spawnClaudeStreamed in TypeScript source for the extension point.
const (
defaultTimeoutMs = 300_000
maxStdoutBytes = 10 * 1024 * 1024
)
// Opts configures a SpawnClaude call.
type Opts struct {
Prompt string
TimeoutMs int // 0 → defaultTimeoutMs
Cwd string // working directory for the claude process
ProfilePath string // optional --settings profile applied to the run
}
// Result holds a successful SpawnClaude response.
type Result struct {
SessionID string
ResultText string
RawJSON string
}
// ClaudeError is returned when the claude process exits with a non-zero status.
type ClaudeError struct {
Msg string
Stderr string
}
func (e *ClaudeError) Error() string {
if e.Stderr != "" {
return fmt.Sprintf("%s: %s", e.Msg, e.Stderr)
}
return e.Msg
}
// ClaudeTimeoutError wraps ClaudeError for timeout-specific failures.
type ClaudeTimeoutError struct {
ClaudeError
}
// Unwrap lets errors.As(err, *ClaudeError) also match a timeout, mirroring the TS IS-A relationship.
func (e *ClaudeTimeoutError) Unwrap() error { return &e.ClaudeError }
// RunClaude is the injectable command runner. Tests replace this to avoid needing
// a real claude binary. The real implementation uses exec.CommandContext.
var RunClaude func(ctx context.Context, cwd string, args []string) (stdout, stderr []byte, err error) = defaultRunClaude
func defaultRunClaude(ctx context.Context, cwd string, args []string) ([]byte, []byte, error) {
cmd := exec.CommandContext(ctx, "claude", args...)
cmd.Env = filteredEnv()
if cwd != "" {
cmd.Dir = cwd
}
stdout, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return stdout, exitErr.Stderr, err
}
return nil, nil, err
}
return stdout, nil, nil
}
// filteredEnv returns os.Environ() minus ANTHROPIC_* and nested Claude guard vars.
func filteredEnv() []string {
blocked := map[string]bool{
"CLAUDECODE": true,
"CLAUDE_CODE_ENTRYPOINT": true,
"CLAUDE_CODE_SESSION_ID": true,
"CLAUDE_CODE_SESSION": true,
}
env := os.Environ()
out := make([]string, 0, len(env))
for _, kv := range env {
key := kv
if idx := strings.IndexByte(kv, '='); idx >= 0 {
key = kv[:idx]
}
if strings.HasPrefix(key, "ANTHROPIC_") || blocked[key] {
continue
}
out = append(out, kv)
}
return out
}
// parsedPayload is the shape claude emits for --output-format json.
type parsedPayload struct {
Result string `json:"result"`
SessionID string `json:"session_id"`
}
// extractClaudePayload parses the stdout from claude.
// It first tries a direct JSON parse; if that fails it scans lines from the end
// looking for the last object that has both result and session_id (NDJSON tail).
func extractClaudePayload(stdout string) (*parsedPayload, bool) {
stdout = strings.TrimSpace(stdout)
var p parsedPayload
if err := json.Unmarshal([]byte(stdout), &p); err == nil && p.SessionID != "" {
return &p, true
}
lines := strings.Split(stdout, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
var candidate parsedPayload
if err := json.Unmarshal([]byte(line), &candidate); err == nil && candidate.SessionID != "" {
return &candidate, true
}
}
return nil, false
}
// SpawnClaude runs claude with --output-format json and returns the parsed result.
func SpawnClaude(ctx context.Context, opts Opts) (*Result, error) {
timeoutMs := opts.TimeoutMs
if timeoutMs <= 0 {
timeoutMs = defaultTimeoutMs
}
tCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
defer cancel()
args := []string{
"--dangerously-skip-permissions",
"-p", opts.Prompt,
"--output-format", "json",
}
if opts.ProfilePath != "" {
args = append(args, "--settings", opts.ProfilePath)
}
stdout, stderr, err := RunClaude(tCtx, opts.Cwd, args)
if err != nil {
if errors.Is(tCtx.Err(), context.DeadlineExceeded) {
return nil, &ClaudeTimeoutError{ClaudeError{
Msg: "claude timed out",
Stderr: string(stderr),
}}
}
return nil, &ClaudeError{
Msg: fmt.Sprintf("claude exited with error: %v", err),
Stderr: string(stderr),
}
}
if len(stdout) > maxStdoutBytes {
return nil, &ClaudeError{Msg: fmt.Sprintf("claude stdout exceeded %d bytes", maxStdoutBytes)}
}
payload, ok := extractClaudePayload(string(stdout))
if !ok {
return nil, &ClaudeError{Msg: "could not parse claude output as JSON"}
}
return &Result{
SessionID: payload.SessionID,
ResultText: payload.Result,
RawJSON: string(stdout),
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
package cc
import (
"context"
"errors"
"testing"
)
func withRunner(fn func(ctx context.Context, cwd string, args []string) ([]byte, []byte, error)) func() {
orig := RunClaude
RunClaude = fn
return func() { RunClaude = orig }
}
func TestSpawnClaude_CleanJSON(t *testing.T) {
restore := withRunner(func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return []byte(`{"result":"ok","session_id":"abc123"}`), nil, nil
})
defer restore()
res, err := SpawnClaude(context.Background(), Opts{Prompt: "hello"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.SessionID != "abc123" {
t.Errorf("SessionID = %q, want abc123", res.SessionID)
}
if res.ResultText != "ok" {
t.Errorf("ResultText = %q, want ok", res.ResultText)
}
}
func TestSpawnClaude_NDJSONTail(t *testing.T) {
ndjson := `{"type":"progress","data":"thinking"}
{"type":"progress","data":"still thinking"}
{"result":"done","session_id":"sess99"}`
restore := withRunner(func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return []byte(ndjson), nil, nil
})
defer restore()
res, err := SpawnClaude(context.Background(), Opts{Prompt: "go"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.SessionID != "sess99" {
t.Errorf("SessionID = %q, want sess99", res.SessionID)
}
if res.ResultText != "done" {
t.Errorf("ResultText = %q, want done", res.ResultText)
}
}
func TestSpawnClaude_NonZeroExit(t *testing.T) {
restore := withRunner(func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
return nil, []byte("something went wrong"), errors.New("exit status 1")
})
defer restore()
_, err := SpawnClaude(context.Background(), Opts{Prompt: "hi"})
if err == nil {
t.Fatal("expected error, got nil")
}
var ce *ClaudeError
if !errors.As(err, &ce) {
t.Errorf("expected ClaudeError, got %T: %v", err, err)
}
}
func TestSpawnClaude_Timeout(t *testing.T) {
restore := withRunner(func(ctx context.Context, _ string, _ []string) ([]byte, []byte, error) {
// Respect context cancellation — as the real runner would.
<-ctx.Done()
return nil, nil, ctx.Err()
})
defer restore()
_, err := SpawnClaude(context.Background(), Opts{Prompt: "slow", TimeoutMs: 1})
if err == nil {
t.Fatal("expected timeout error, got nil")
}
var te *ClaudeTimeoutError
if !errors.As(err, &te) {
t.Errorf("expected ClaudeTimeoutError, got %T: %v", err, err)
}
}