Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Release CLI

on:
push:
tags:
- "v*"
workflow_dispatch:

permissions:
contents: write

jobs:
build-macos:
name: Build macOS CLI
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
target:
- aarch64-apple-darwin
- x86_64-apple-darwin
steps:
- name: Validate release tag
if: startsWith(github.ref, 'refs/tags/')
run: |
if ! echo "${GITHUB_REF_NAME}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Release tags must use semantic version format: vX.Y.Z"
exit 1
fi

- name: Checkout
uses: actions/checkout@v4

- name: Install Rust target
run: rustup target add ${{ matrix.target }}

- name: Build perch CLI
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}

- name: Package binary
run: |
cp cli/target/${{ matrix.target }}/release/perch perch-${{ matrix.target }}
shasum -a 256 perch-${{ matrix.target }} > perch-${{ matrix.target }}.sha256

- name: Upload workflow artifacts
uses: actions/upload-artifact@v4
with:
name: perch-${{ matrix.target }}
path: |
perch-${{ matrix.target }}
perch-${{ matrix.target }}.sha256

- name: Upload release assets
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 || \
gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes
if ! gh release upload "${GITHUB_REF_NAME}" \
perch-${{ matrix.target }} \
perch-${{ matrix.target }}.sha256 \
--clobber; then
echo "Failed to upload release assets"
exit 1
fi
gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name'
46 changes: 44 additions & 2 deletions App/Sources/PerchAppCore/StatusMenuController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ enum StatusMenuLogic {
return "cd '\(dir)' && \(session.resumeCmd)"
}

static func projectLabel(from workingDir: String) -> String {
guard !workingDir.isEmpty else { return "" }
let name = URL(fileURLWithPath: workingDir).lastPathComponent
return name.isEmpty ? workingDir : name
}

static func groupedByProject(from sessions: [Session]) -> [(label: String, sessions: [Session])] {
var groups: [(label: String, sessions: [Session])] = []
var index: [String: Int] = [:]
for session in sessions {
let key = (session.workingDir as NSString).standardizingPath
let label = projectLabel(from: session.workingDir)
if let i = index[key] {
groups[i].sessions.append(session)
} else {
index[key] = groups.count
groups.append((label: label, sessions: [session]))
}
}
return groups
}

static func relativeTime(from iso8601: String, now: Date = Date()) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
Expand Down Expand Up @@ -175,8 +197,15 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
emptyItem.isEnabled = false
menu.addItem(emptyItem)
} else {
for session in pending {
menu.addItem(makeSessionItem(session, isDone: false))
let projectGroups = StatusMenuLogic.groupedByProject(from: pending)
let useGroupHeaders = projectGroups.count > 1
for group in projectGroups {
if useGroupHeaders {
menu.addItem(makeProjectHeader(group.label))
}
for session in group.sessions {
menu.addItem(makeSessionItem(session, isDone: false))
}
}
}

Expand Down Expand Up @@ -236,6 +265,19 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
menu.addItem(quitItem)
}

private func makeProjectHeader(_ label: String) -> NSMenuItem {
let item = NSMenuItem(title: label, action: nil, keyEquivalent: "")
item.isEnabled = false
item.attributedTitle = NSAttributedString(
string: label,
attributes: [
.font: NSFont.systemFont(ofSize: 11, weight: .medium),
.foregroundColor: NSColor.secondaryLabelColor
]
)
return item
}

private func makeSessionItem(_ session: Session, isDone: Bool) -> NSMenuItem {
let time = StatusMenuLogic.relativeTime(from: session.createdAt)
let item = NSMenuItem(
Expand Down
96 changes: 96 additions & 0 deletions App/Tests/PerchAppCoreTests/PerchAppCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,102 @@ final class PerchAppCoreTests: XCTestCase {
XCTAssertEqual(groups.done.map(\.id), ["done"])
}

func testStatusMenuLogicExtractsProjectLabelFromPath() {
XCTAssertEqual(StatusMenuLogic.projectLabel(from: "/Users/yilin/Developer/Perch"), "Perch")
XCTAssertEqual(StatusMenuLogic.projectLabel(from: "/tmp/my-project"), "my-project")
XCTAssertEqual(StatusMenuLogic.projectLabel(from: "/root"), "root")
XCTAssertEqual(StatusMenuLogic.projectLabel(from: ""), "")
}

func testStatusMenuLogicGroupsByProjectPreservingSessionOrder() {
let p1a = sampleSession(id: "p1a", workingDir: "/projects/alpha", title: "Alpha 1")
let p1b = sampleSession(id: "p1b", workingDir: "/projects/alpha", title: "Alpha 2")
let p2a = sampleSession(id: "p2a", workingDir: "/projects/beta", title: "Beta 1")

let groups = StatusMenuLogic.groupedByProject(from: [p1a, p1b, p2a])

XCTAssertEqual(groups.count, 2)
XCTAssertEqual(groups[0].label, "alpha")
XCTAssertEqual(groups[0].sessions.map(\.id), ["p1a", "p1b"])
XCTAssertEqual(groups[1].label, "beta")
XCTAssertEqual(groups[1].sessions.map(\.id), ["p2a"])
}

func testStatusMenuLogicGroupsByProjectSingleProjectProducesOneGroup() {
let s1 = sampleSession(id: "s1", workingDir: "/projects/alpha")
let s2 = sampleSession(id: "s2", workingDir: "/projects/alpha")

let groups = StatusMenuLogic.groupedByProject(from: [s1, s2])

XCTAssertEqual(groups.count, 1)
XCTAssertEqual(groups[0].sessions.map(\.id), ["s1", "s2"])
}

func testStatusMenuLogicGroupsByFullPathWhenLabelsCollide() {
let homeApp = sampleSession(id: "home", workingDir: "/Users/yilin/app")
let tmpApp = sampleSession(id: "tmp", workingDir: "/tmp/app")

let groups = StatusMenuLogic.groupedByProject(from: [homeApp, tmpApp])

XCTAssertEqual(groups.count, 2)
XCTAssertEqual(groups[0].label, "app")
XCTAssertEqual(groups[0].sessions.map(\.id), ["home"])
XCTAssertEqual(groups[1].label, "app")
XCTAssertEqual(groups[1].sessions.map(\.id), ["tmp"])
}

func testStatusMenuLogicNormalizesProjectPathBeforeGrouping() {
let s1 = sampleSession(id: "s1", workingDir: "/projects/alpha")
let s2 = sampleSession(id: "s2", workingDir: "/projects/./alpha/")

let groups = StatusMenuLogic.groupedByProject(from: [s1, s2])

XCTAssertEqual(groups.count, 1)
XCTAssertEqual(groups[0].sessions.map(\.id), ["s1", "s2"])
}

func testStatusMenuControllerRendersProjectHeadersForMultipleProjects() {
let alpha = sampleSession(id: "a", workingDir: "/projects/Alpha", title: "Task A", status: "pending")
let beta = sampleSession(id: "b", workingDir: "/projects/Beta", title: "Task B", status: "pending")
let controller = StatusMenuController(
sessionLoader: { [alpha, beta] },
configLoader: { PerchConfig() },
statusWriter: { _, _ in },
pasteboard: .withUniqueName(),
watchFile: false
)

controller.rebuildMenu()

let titles = controller.menu.items.map(\.title)
XCTAssertTrue(titles.contains("Alpha"), "Expected project header 'Alpha' in \(titles)")
XCTAssertTrue(titles.contains("Beta"), "Expected project header 'Beta' in \(titles)")
let alphaIdx = try! XCTUnwrap(titles.firstIndex(of: "Alpha"))
let taskAIdx = try! XCTUnwrap(titles.firstIndex { $0.contains("Task A") })
let betaIdx = try! XCTUnwrap(titles.firstIndex(of: "Beta"))
let taskBIdx = try! XCTUnwrap(titles.firstIndex { $0.contains("Task B") })
XCTAssertLessThan(alphaIdx, taskAIdx)
XCTAssertLessThan(betaIdx, taskBIdx)
}

func testStatusMenuControllerOmitsProjectHeadersForSingleProject() {
let s1 = sampleSession(id: "s1", workingDir: "/projects/Perch", title: "Task 1", status: "pending")
let s2 = sampleSession(id: "s2", workingDir: "/projects/Perch", title: "Task 2", status: "pending")
let controller = StatusMenuController(
sessionLoader: { [s1, s2] },
configLoader: { PerchConfig() },
statusWriter: { _, _ in },
pasteboard: .withUniqueName(),
watchFile: false
)

controller.rebuildMenu()

XCTAssertFalse(controller.menu.items.contains { $0.title == "Perch" && !$0.isEnabled },
"Should not add a project header when all sessions share one project")
XCTAssertTrue(controller.menu.items[0].title.contains("Task 1"))
}

func testStatusMenuLogicMapsAgentIconResourceNames() {
let cases: [(String, String)] = [
("claude", "claudecode"),
Expand Down
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
# Perch

Perch is a lightweight macOS menu bar app for parking AI coding sessions and resuming them later. Run `/perch` inside a supported coding agent, and Perch saves the current session into `~/.config/perch/sessions.json`. The menu bar app lists pending sessions; click one to copy a project-aware resume command to the clipboard.
Perch helps developers park AI coding sessions and resume them later from the CLI, menu bar, or Raycast. Run `/perch` inside a supported coding agent, and Perch saves the current session into `~/.config/perch/sessions.json`.

## 60-Second Quick Start

Install the Perch CLI and supported agent `/perch` commands:

```sh
curl -fsSL https://raw.githubusercontent.com/ReScienceLab/Perch/main/install.sh | sh
```

Then:

1. Make sure `~/.local/bin` is in your shell `PATH`.
2. Restart your coding agent.
3. Type `/perch My task title` inside Claude Code, Codex, Pi, Droid, or OpenCode to save the current session.
4. Resume from Raycast with **Search Sessions**, from the menu bar, or by running `perch list` and copying the resume command.
5. Verify setup any time with:

```sh
perch doctor
```

Perch stores sessions locally only; no service or account is required.

## Highlights

Expand Down Expand Up @@ -30,6 +52,14 @@ Perch is a lightweight macOS menu bar app for parking AI coding sessions and res

## Install

Recommended remote install:

```sh
curl -fsSL https://raw.githubusercontent.com/ReScienceLab/Perch/main/install.sh | sh
```

Local repo install for contributors:

```sh
git clone https://github.com/ReScienceLab/Perch.git
cd Perch
Expand All @@ -38,12 +68,13 @@ cd Perch

The installer is idempotent. It:

- Builds and installs the `perch` CLI to `~/.local/bin/perch`.
- Installs the `perch` CLI to `~/.local/bin/perch`, using the latest GitHub Release prebuilt binary by default.
- Falls back to a local Cargo build only when running from a cloned repo and release download is unavailable.
- Installs the same agent-agnostic command from `Commands/perch.md` for every detected agent.
- Creates `~/.config/perch/sessions.json` if missing.
- Creates `~/.config/perch/config` if missing.

Make sure `~/.local/bin` is in your shell `PATH` and is also visible inside your coding agents.
Make sure `~/.local/bin` is in your shell `PATH` and is also visible inside your coding agents. Run `perch doctor` after installation for actionable diagnostics.

### Installed command locations

Expand Down Expand Up @@ -163,6 +194,8 @@ perch done <perch-id-or-prefix>
perch done --session-id <agent-session-id>
perch reopen <perch-id-or-prefix>
perch reopen --session-id <agent-session-id>
perch doctor
perch doctor --json
```

## Save Semantics
Expand Down
Loading