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
11 changes: 11 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changesets

Version and changelog for GitHub releases. This repo is a Swift product: the root `package.json` is private and exists only so Changesets can bump `vX.Y.Z` tags and write `CHANGELOG.md`. Nothing is published to npm.

## Workflow

1. With a user-facing change, run `npm run changeset` (or `npx changeset`) and pick patch / minor / major. Internal-only work (CI, docs for agents, refactor with no behavior change) never gets a changeset.
2. Merge the PR. On `main`, the Release workflow opens or updates a **Version Packages** PR.
3. Merge that PR when ready. The workflow tags `vX.Y.Z` (matching historical releases) and creates the GitHub release from the changelog.

`npm run version` also syncs `Sources/DevCtlKit/Model/Models.swift` (`DevCtlVersion.version`) from `package.json` so `devctl --version` and the app bundle stay aligned.
19 changes: 19 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "quantizor/devctl" }
],
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"prettier": false,
"privatePackages": {
"version": true,
"tag": true
}
}
5 changes: 5 additions & 0 deletions .changeset/cursor-session-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": minor
---

Add Cursor sessionStart harness (`devctl hook install --harness cursor`) so Agent sessions get live `<devctl-servers>` context.
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Minimal macOS gate: compile every product and run unit tests.
# Intentionally skips scripts/smoke.sh and smoke-launchd.sh (longer, and
# launchd mutates the runner). Standard macos-26 arm64 only: no large /
# xlarge / intel labels (those bill more for the same work).
name: CI

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build:
runs-on: macos-26
timeout-minutes: 12
steps:
- uses: actions/checkout@v4

- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: |
.build
~/Library/Caches/org.swift.swiftpm
key: spm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-${{ runner.arch }}-

- name: Build
run: swift build

- name: Test
run: swift test
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# GitHub-only releases via Changesets (ubuntu; no macOS minutes).
# Creates/updates the Version Packages PR; on merge, tags vX.Y.Z and
# opens a GitHub release from CHANGELOG.md. Does not publish to npm.
name: Release

on:
push:
branches: [main]
workflow_dispatch:

concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
contents: write
pull-requests: write

jobs:
release:
name: Release
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm

- name: Install dependencies
run: npm ci

- name: Create Release Pull Request or GitHub Release
id: changesets
uses: changesets/action@v1
with:
title: Version Packages
commit: Version Packages
version: npm run version
publish: npm run release
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
.build/
.swiftpm/
devctl.app/
Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Open work only; entries are removed by the change that resolves them.

- DMG distribution with one-click install (Evan, 2026-07-19): a simple dmg builder (scripts/make-dmg.sh from the existing bundle pipeline; hdiutil + a drag-to-Applications layout) where first launch of devctl.app completes setup itself: install the CLI/daemon binaries from the app bundle's Resources, run daemon install, and auto-detect installed agent harnesses (Claude Code via ~/.claude presence → offer/perform `hook install --harness claude`), with a small first-run panel reporting what was set up. Requires Developer ID signing + notarization (already backlogged) to be download-safe; sequence the two together.
- DMG distribution with one-click install (Evan, 2026-07-19): a simple dmg builder (scripts/make-dmg.sh from the existing bundle pipeline; hdiutil + a drag-to-Applications layout) where first launch of devctl.app completes setup itself: install the CLI/daemon binaries from the app bundle's Resources, run daemon install, and auto-detect installed agent harnesses (Claude Code via ~/.claude presence → offer/perform `hook install --harness claude`; Cursor via ~/.cursor presence → `hook install --harness cursor`), with a small first-run panel reporting what was set up. Requires Developer ID signing + notarization (already backlogged) to be download-safe; sequence the two together.
- Orphan re-adoption without the bounce: after a daemon crash, re-adopt live orphan servers (pid + start-time match, resume spool tailing) instead of group-kill + restart. Blocked on: exit codes are unknowable for non-children; needs a design for degraded forensics.
- Reverse proxy on :80/:443 routing by host signature, making ports disappear from `*.localhost` URLs (Valet/Herd territory).
- MenuBarExtraAccess (orchetect) if `.window` presentation quirks bite in practice.
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Changelog

All notable changes to this project are documented here. Releases are tagged
`vX.Y.Z` on GitHub (same scheme Changesets uses for this root package).

## 1.1.0

### Patch Changes

- Fixed a health check issue with some dev servers that primarily communicate over IPv6.
- Many design improvements.
- Keyboard navigation.
- Sorting & Filtering.

## 1.0.0

Initial public release.
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# devctl

macOS menu bar command center for local dev servers, built so coding agents never lose track of running servers across compaction and session boundaries. All Swift, no Xcode. Design spec and phase plan: docs/design.md (the map for everything not yet built). CLI JSON contract: docs/cli-contract.md. Open work: BACKLOG.md.
macOS menu bar command center for local dev servers, built so coding agents never lose track of running servers across compaction and session boundaries. All Swift, no Xcode. Design spec and phase plan: docs/design.md (the map for everything not yet built). CLI JSON contract: docs/cli-contract.md. Open work: BACKLOG.md. Commit and changeset hygiene: CONTRIBUTING.md.

Identity and stack
- Swift 6.3 toolchain, swift-tools-version 6.2, Swift 6 language mode with strict concurrency. macOS 14+, single SPM package.
Expand Down Expand Up @@ -45,7 +45,7 @@ Engineering rules
- Fields in type declarations, initializers, and literals are alphabetized; keep a non-alphabetical order only where the sequence is load-bearing and say why in a comment. Persisted names (JSON keys, file names) are plain English a non-engineer would read.
- Error messages assume the reader knows nothing of internals: what happened, where, and the exact fix; wire errors carry a stable code and a hint that is the literal command to run.
- Prose (docs, commits, PRs): American English, no em-dashes (use a colon, comma, or period), plain language over jargon, no attribution footers anywhere. PR and changeset descriptions lead with user-facing impact, not mechanism.
- Git: never git stash; use temp commits. The maintainer handles version bumps and publishing. A dependency change updates Package.resolved in the same commit and is verified by a build.
- Git: never git stash; use temp commits. A dependency change updates Package.resolved in the same commit and is verified by a build. Commit messages, Changesets (when to write one), and release tagging live in CONTRIBUTING.md; agents never bump versions or publish.
- Docs carry no measured drift-prone numbers (timings, counts, coverage); state the budget and the command that prints the live figure. Docs track current state, never completions or history. BACKLOG.md is the only backlog, holds open work only, and the change that resolves an entry removes it.
- Performance: hot paths (spool tailer, log query) get temporary microbenchmarks before an approach is chosen; a governor (throttle, cap) is never the fix for a cost problem, make the work itself cheaper.
- No database, no migrations; on-disk compatibility is the defensive-load rule above.
Expand Down
17 changes: 14 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
# Contributing to devctl

devctl is a personal tool first; issues and patches are welcome all the same. Read `CLAUDE.md` (or its `AGENTS.md` symlink) for the codebase map, invariants, and commands, and `docs/cli-contract.md` for the JSON surface. `make test` and `scripts/smoke.sh` must pass; `scripts/smoke-launchd.sh` exercises the real launchd lifecycle if your change touches daemon management.
devctl is a personal tool first; issues and patches are welcome all the same. Read `CLAUDE.md` (or its `AGENTS.md` symlink) for the codebase map, invariants, and commands, and `docs/cli-contract.md` for the JSON surface. `make test` and `scripts/smoke.sh` must pass locally; `scripts/smoke-launchd.sh` exercises the real launchd lifecycle if your change touches daemon management. GitHub Actions (`.github/workflows/ci.yml`) runs `swift build` + `swift test` on `macos-26` only: no smoke, no large runners.

## Commit and changeset hygiene

- Commits focus on why, not a file list; American English; no em-dashes; no attribution footers.
- Never `git stash` (use temp commits). Agents never bump versions or publish.
- User-facing changes get a Changeset (`npm run changeset`); see `.changeset/README.md`. Internal-only work (CI, agent docs, no-behavior refactors) never gets a changeset.
- Product version lives in `package.json`. `npm run version` (used by the Release workflow) syncs `DevCtlVersion.version` in `Sources/DevCtlKit/Model/Models.swift`.

## Releases

GitHub releases are driven by [Changesets](https://github.com/changesets/changesets). After changesets land on `main`, a Version Packages PR appears; merging it tags `vX.Y.Z` and opens the GitHub release. Nothing is published to npm: the root `package.json` is private and only tracks the product version.

## Adding an agent-harness adapter

The session-context payload is harness-agnostic: `devctl context` prints a fenced plain-text block describing the current project's servers, and `devctl statusline` prints a one-line presence summary from statusline stdin JSON. Wiring those into a harness is the only per-harness work.

1. Conform to `HarnessAdapter` in `Sources/devctl/HookSupport.swift`: a `name` (the `--harness` value) and an idempotent `install(devctlPath:)` that merges a session-start hook into the harness's settings without clobbering what is already there.
2. If the harness wants a structured payload (as Claude Code does with `hookSpecificOutput.additionalContext`), add a hidden subcommand like `HookClaudeSessionStart` that adapts `AgentContext.render` output to that shape. Keep the guarantees: exit 0 always, fast, silent when there is nothing to say, never auto-starting the daemon, and never emitting raw log lines or command strings (child output and committed configs are attacker-influenceable).
2. If the harness wants a structured payload (as Claude Code does with `hookSpecificOutput.additionalContext`, or Cursor with `{additional_context}`), add a hidden subcommand like `HookClaudeSessionStart` / `HookCursorSessionStart` that adapts `AgentContext.render` output to that shape. Keep the guarantees: exit 0 always, fast, silent when there is nothing to say, never auto-starting the daemon, and never emitting raw log lines or command strings (child output and committed configs are attacker-influenceable). Resolve the session directory via `HookSessionCwd` (Cursor: `workspace_roots` / `CURSOR_PROJECT_DIR`; Claude: `cwd`).
3. Register the adapter in `harnessAdapters` and document the harness in `docs/cli-contract.md` under `devctl hook install`.

A harness that only supports plain-text injection needs no adapter code at all: point its hook at `devctl context`.
A harness that only supports plain-text injection needs no adapter code at all: point its hook at `devctl context`. Shipped adapters today: `claude`, `cursor`.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ cd your-project
devctl register --name web --cmd bun --cmd run --cmd dev --port 3000
devctl ensure web # idempotent: healthy is a no-op
devctl why web # root cause when something breaks
devctl hook install # Claude Code sessions rediscover servers automatically
devctl hook install --harness cursor # Cursor Agent sessions rediscover servers automatically
devctl hook install --harness claude # same for Claude Code (default harness)
```

Or commit a `devservers.json` at the project root (multiple servers, dependencies, healthchecks, `*.localhost` host signatures, multi-headed proxies, lifecycle playbooks); `devctl up` brings the whole project up in dependency order. The full CLI contract lives in [docs/cli-contract.md](./docs/cli-contract.md).
Expand Down
2 changes: 1 addition & 1 deletion Sources/DevCtlKit/Model/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
public enum DevCtlVersion {
/** Wire protocol major version; clients abort on mismatch with `version-mismatch`. */
public static let proto = 1
public static let version = "0.1.0"
public static let version = "1.1.0"
}

/** Lifecycle phase of a supervised server. `failed` means the spawn itself never
Expand Down
32 changes: 23 additions & 9 deletions Sources/devctl/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ struct HookCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "hook",
abstract: "Agent-harness session hooks.",
subcommands: [HookInstall.self, HookClaudeSessionStart.self]
subcommands: [HookInstall.self, HookClaudeSessionStart.self, HookCursorSessionStart.self]
)
}

Expand All @@ -624,7 +624,7 @@ struct HookInstall: AsyncParsableCommand {

@OptionGroup var global: GlobalOptions

@Option(help: "Harness to install for: \(harnessAdapters.map(\.name).joined(separator: ", ")).")
@Option(help: "Harness to install for: \(harnessAdapters.map(\.name).joined(separator: ", ")) (default: claude).")
var harness: String = "claude"

@Flag(help: "Also print the statusline wiring suggestion.")
Expand All @@ -639,8 +639,7 @@ struct HookInstall: AsyncParsableCommand {
message: "unknown harness '\(harness)'"),
json: global.json)
}
let devctlPath = URL(fileURLWithPath: CommandLine.arguments[0])
.resolvingSymlinksInPath().path
let devctlPath = CLISelf.path
do {
let summary = try adapter.install(devctlPath: devctlPath)
var output = summary
Expand All @@ -665,11 +664,7 @@ struct HookClaudeSessionStart: AsyncParsableCommand {

func run() async throws {
let stdin = FileHandle.standardInput.readDataToEndOfFile()
var cwd = FileManager.default.currentDirectoryPath
if let payload = try? JSONSerialization.jsonObject(with: stdin) as? [String: Any],
let hookCwd = payload["cwd"] as? String {
cwd = hookCwd
}
let cwd = HookSessionCwd.resolve(stdin: stdin)
/** Project resolution without --project: reuse the CLI's walk from the
hook cwd by chdir-ing there first. */
FileManager.default.changeCurrentDirectoryPath(cwd)
Expand All @@ -687,6 +682,25 @@ struct HookClaudeSessionStart: AsyncParsableCommand {
}
}

/** Invoked by Cursor's sessionStart hook. Emits {additional_context} (Cursor's
snake_case schema). Same silence / exit-0 guarantees as the Claude hook. */
struct HookCursorSessionStart: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "cursor-session-start", shouldDisplay: false)

func run() async throws {
let stdin = FileHandle.standardInput.readDataToEndOfFile()
let cwd = HookSessionCwd.resolve(stdin: stdin)
FileManager.default.changeCurrentDirectoryPath(cwd)
let project = GlobalOptions.resolveProject(from: cwd)
guard let text = await AgentContext.render(project: project) else { return }
let output: [String: Any] = ["additional_context": text]
if let data = try? JSONSerialization.data(withJSONObject: output) {
FileHandle.standardOutput.write(data)
}
}
}

/** Statusline helper: reads the harness's statusline stdin JSON, prints one
compact presence line for the cwd's project (empty when nothing to show). */
struct Statusline: AsyncParsableCommand {
Expand Down
Loading
Loading