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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ dev # List all available commands

The tool walks up from your current directory until it finds a git repo root (directory containing `.git`), then looks for `dev.yml` there. If found, it parses the commands and executes the `run` string for your chosen subcommand.

A few builtins are global and work from **any** directory, no `dev.yml` needed: `dev cd` (host-global navigation), `dev cred` (host-global credentials), and `dev plan` (workspace-global plan sync). Project commands (`dev up` and anything declared in `dev.yml`) still require a nearby `dev.yml`.
A few builtins are global and work from **any** directory, no `dev.yml` needed: `dev cd` (host-global navigation), `dev clone` (host-global checkout creation), `dev cred` (host-global credentials), and `dev plan` (workspace-global plan sync). Project commands (`dev up` and anything declared in `dev.yml`) still require a nearby `dev.yml`.

## dev cd — jump between checkouts

Expand All @@ -123,9 +123,22 @@ export DEV_CD_ROOT=/path/to/checkouts

Only git repos count as candidates (directories with a `.git` entry — a `.git` file from a worktree checkout works too); plain folders are skipped. The query is a right-anchored path suffix matched per segment: `dev` matches the leaf, `d3mlabs/dev` the org and leaf, `bitbucket.org/d3mlabs/dev` the host too — a more explicit path always works. On an ambiguous query, `dev cd` lists the candidates (each at the shortest depth that makes it unique, capped at 10) and exits non-zero; refine the query or press Tab to browse all matches. On no match it errors clearly.

## dev clone — clone into the canonical layout

`dev clone [<org>/]<repo>` clones a GitHub repo (via your `gh` auth — no credentials of dev's own) into the canonical checkout path under the same search root `dev cd` walks — `$DEV_CD_ROOT/github.com/<org>/<repo>`, default `~/src` — and lands your shell in the fresh checkout through the same wrapper:

```bash
dev clone myrepo # org defaults to d3mlabs → ~/src/github.com/d3mlabs/myrepo
dev clone acme/widget # explicit org
```

It is clone-only by design — no automatic `dev up`. Provisioning stays a deliberate second step, because a first `dev up` is where credential prompts happen and you should see them coming. The fresh-machine story is three commands: `brew install d3mlabs/d3mlabs/dev` → `dev clone <repo>` → `dev up`.

If the canonical destination already exists, `dev clone` errors and points you at `dev cd`. Without the shell wrapper active (e.g. the very first dev command on a fresh machine), the clone still happens; dev installs the hook for next time and prints the destination instead of jumping there.

### Shell hook install

`dev cd` needs a small shell wrapper — a Ruby child process cannot change your shell's directory. dev installs the wrapper function and Tab completers into your shell RC automatically and idempotently: on `dev up` in any project, and on `dev cd` itself (so a first `dev cd` self-heals the hook; open a new shell after the install hint). The snippet is marker-guarded (`# dev cd (added by dev)`) next to the shadowenv one, and re-runs never duplicate it.
`dev cd` and the landing half of `dev clone` need a small shell wrapper — a Ruby child process cannot change your shell's directory. dev installs the wrapper function and Tab completers into your shell RC automatically and idempotently: on `dev up` in any project, and on `dev cd` / a hook-less `dev clone` themselves (so a first use self-heals the hook; open a new shell after the install hint). The snippet is marker-guarded (`# dev cd + clone (added by dev)`) next to the shadowenv one, and re-runs never duplicate it; when the snippet itself evolves, the marker changes with it and the next ensure appends the updated wrapper, whose later definition wins.

Tab completion is registered per shell: zsh gets a navigable menu-select list scoped to the `dev` command only (your other commands' completion is untouched; registration is skipped quietly if your zshrc never runs `compinit`), bash fills `COMPREPLY` directly, and fish registers a standard pager completion (fish applies its own filtering, so fuzzy tokens may only complete literally there). Completion fills the argument only — it never runs the `cd` for you — and inserts `org/repo` (or deeper) forms when a short name would collide.

Expand Down Expand Up @@ -360,6 +373,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins,
- **`dev deps path <integration> <name> <platform>`** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions.
- **`dev cred get <namespace> <key>`** — resolve a credential through the provider chain (ENV → keychain → file → prompt) and print it. A non-interactive miss errors with `gh secret set` guidance. Mirrors `dev deps path` for shell consumers (e.g. a staging sync). Global: works without a `dev.yml`.
- **`dev cd <repo>`** — jump to a checkout under `$DEV_CD_ROOT` (default `~/src`) by fuzzy name, with Tab completion (see [dev cd](#dev-cd--jump-between-checkouts)). Global: works without a `dev.yml`.
- **`dev clone [<org>/]<repo>`** — clone a GitHub repo via your `gh` auth into the canonical `$DEV_CD_ROOT/github.com/<org>/<repo>` path (org defaults to `d3mlabs`) and land there (see [dev clone](#dev-clone--clone-into-the-canonical-layout)). Clone-only — run `dev up` yourself. Global: works without a `dev.yml`.
- **`dev cache gc [--keep N]`** — reclaim host caches dev owns (see below).
- **`dev reset-container`** — remove the persistent build container (clears its incremental cache); registered only when `build.container.persist` is set.
- **`dev plan …`** — global (works without a `dev.yml`; the workspace is the nearest dev.yml or git root). Sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh-<n>-<slug>.plan.md` is a transient working copy carrying an `<!-- ai-flow … -->` header. Subcommands: `new "<title>" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>|<n>]` (guarded body PATCH — refuses to clobber newer remote edits; a number resolves the linked plan like `pull`), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also refreshes the user-global links for dev's shipped skills (`share/cursor-skills/*` → `~/.cursor/skills/`, so the Cursor agent knows these verbs) and the org learnings artifacts (see [Agent skills & org learnings](#agent-skills--org-learnings)). For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md).
Expand Down
34 changes: 28 additions & 6 deletions lib/dev/cd/hook_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

module Dev
module Cd
# Installs the `dev cd` shell hook through the shared RC-hook installer.
# Installs the `dev cd` / `dev clone` shell hook through the shared
# RC-hook installer.
#
# The hook is a `dev()` wrapper function (a Ruby child cannot change the
# parent shell's cwd): it intercepts `dev cd …`, resolves the target via
# the hidden `--resolve` plumbing, and `builtin cd`s into it in the
# current shell — so chpwd hooks (e.g. shadowenv) fire exactly as they
# would for a manual `cd`. Everything else falls through to `command dev`.
# parent shell's cwd): it intercepts `dev cd …` and `dev clone …`,
# resolves the target directory via the hidden plumbing modes (`cd
# --resolve` matches an existing checkout; `clone --path` clones then
# prints the destination), and `builtin cd`s into it in the current
# shell — so chpwd hooks (e.g. shadowenv) fire exactly as they would for
# a manual `cd`. Everything else falls through to `command dev`.
#
# Each snippet also registers Tab completion backed by `--candidates`.
# Completion replaces the typed token with the rendered candidate (fuzzy
Expand All @@ -20,7 +23,11 @@ module Cd
# registers a `complete -c dev` source (fish applies its own filtering,
# so fuzzy tokens may complete only literally there).
class HookInstaller
MARKER = "# dev cd (added by dev)"
# The marker names the snippet generation: RCs carrying only an older
# marker get the current snippet appended on the next ensure (dev up or
# any `dev cd`), and the later function definition wins in every
# supported shell — self-healing updates without RC surgery.
MARKER = "# dev cd + clone (added by dev)"

ZSH_SNIPPET = <<~'SNIPPET'
dev() {
Expand All @@ -29,6 +36,11 @@ class HookInstaller
local __dev_cd_target
__dev_cd_target="$(command dev cd --resolve "$@")" || return $?
builtin cd -- "$__dev_cd_target"
elif [[ "$1" == clone ]]; then
shift
local __dev_clone_target
__dev_clone_target="$(command dev clone --path "$@")" || return $?
builtin cd -- "$__dev_clone_target"
else
command dev "$@"
fi
Expand All @@ -53,6 +65,11 @@ class HookInstaller
local __dev_cd_target
__dev_cd_target="$(command dev cd --resolve "$@")" || return $?
builtin cd -- "$__dev_cd_target"
elif [ "$1" = clone ]; then
shift
local __dev_clone_target
__dev_clone_target="$(command dev clone --path "$@")" || return $?
builtin cd -- "$__dev_clone_target"
else
command dev "$@"
fi
Expand All @@ -74,6 +91,11 @@ class HookInstaller
set -l __dev_cd_target (command dev cd --resolve $argv)
or return $status
builtin cd $__dev_cd_target
else if test (count $argv) -ge 1; and test "$argv[1]" = clone
set -e argv[1]
set -l __dev_clone_target (command dev clone --path $argv)
or return $status
builtin cd $__dev_clone_target
else
command dev $argv
end
Expand Down
14 changes: 14 additions & 0 deletions lib/dev/clone.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

require "dev/clone/repo_spec"
require "dev/clone/gh_cloner"
require "dev/clone/accessor"

module Dev
# `dev clone`: clone a GitHub repo via the user's gh auth into the canonical
# checkout layout under $DEV_CD_ROOT (default ~/src), landing the shell in
# the fresh checkout through the same wrapper that powers `dev cd`. See
# Dev::Clone::Accessor for the command surface.
module Clone
end
end
96 changes: 96 additions & 0 deletions lib/dev/clone/accessor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

require "pathname"
require "dev/clone/repo_spec"
require "dev/clone/gh_cloner"
require "dev/cd/hook_installer"

module Dev
module Clone
# Dispatch for `dev clone …` — clone a GitHub repo via the user's gh auth
# into the canonical checkout layout under the search root ($DEV_CD_ROOT,
# default ~/src): `github.com/<org>/<repo>`. Clone only — no `dev up`:
# provisioning stays a deliberate second step, where credential prompts
# are expected.
#
# The human command is `dev clone [<org>/]<repo>` (org defaults to
# d3mlabs), handled by the installed shell wrapper (the same one that
# powers `dev cd`); the wrapper calls the hidden plumbing mode:
#
# - `--path [<org>/]<repo>`: clone, then print exactly the destination's
# absolute path on stdout (the wrapper `builtin cd`s into it)
#
# A bare `dev clone …` reaching this process means the wrapper isn't
# active in the calling shell. Unlike `dev cd`, the clone still happens —
# it is the useful work, and on a fresh machine `dev clone` runs before
# any hook exists — then the hook self-heals and the destination is
# explained instead of landed in.
class Accessor
# `dev clone` was invoked with the wrong arguments.
class UsageError < RuntimeError; end

# The canonical destination already exists on disk.
class DestinationExistsError < RuntimeError; end

# @param root [String, Pathname] checkout root (default: $DEV_CD_ROOT, else ~/src)
# @param cloner [Dev::Clone::GhCloner]
# @param hook_installer [Dev::Cd::HookInstaller]
def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"),
cloner: GhCloner.new, hook_installer: Dev::Cd::HookInstaller.new)
@root = Pathname(root).expand_path
@cloner = cloner
@hook_installer = hook_installer
end

# Dispatch a `dev clone …` invocation.
#
# @param args [Array<String>] argv after the "clone" command
# @param out [IO] stdout (the machine-readable payload only)
# @param err [IO] stderr (progress, diagnostics and hints)
# @return [void]
# @raise [UsageError] unless exactly one clone target is given
# @raise [RepoSpec::MalformedRepoError] when the target isn't "<repo>" or "<org>/<repo>"
# @raise [DestinationExistsError] when the canonical path already exists
# @raise [GhCloner::CloneFailedError] when the clone itself fails
def run(args, out: $stdout, err: $stderr)
plumbing = args.first == "--path"
query = plumbing ? args.drop(1) : args
raise UsageError, "usage: dev clone [<org>/]<repo>" unless query.size == 1

spec = RepoSpec.parse(query.fetch(0))
destination = @root / spec.relative_path
if destination.exist?
raise DestinationExistsError, "#{destination} already exists — jump there with `dev cd #{spec.name}`"
end

@cloner.clone(spec.full_name, destination)
plumbing ? out.puts(destination) : announce(spec, destination, err:)
end

private

# Report a hook-less clone: where it landed, and how to get the landing
# behavior next time. Also self-heals the hook — a fresh machine's first
# `dev clone` runs before any `dev up` had a chance to install it.
#
# @param spec [Dev::Clone::RepoSpec]
# @param destination [Pathname]
# @param err [IO]
# @return [void]
def announce(spec, destination, err:)
err.puts "dev: cloned #{spec.full_name} to #{destination}"
case @hook_installer.ensure_installed
when :added
err.puts "dev: shell hook installed — open a new shell and `dev clone` will land you in the checkout. " \
"For now: cd #{destination}"
when :already_present
err.puts "dev: the dev shell hook is installed but not active in this shell — open a new shell " \
"(or source your shell RC). For now: cd #{destination}"
else
err.puts "dev: your shell is unsupported for hooks (supported: zsh, bash, fish). " \
"Jump there with: cd #{destination}"
end
end
end
end
end
49 changes: 49 additions & 0 deletions lib/dev/clone/gh_cloner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

require "fileutils"
require "pathname"

module Dev
module Clone
# Clones GitHub repos through `gh`, so the fetch rides the user's gh auth
# (dev is public and carries no credentials of its own — the same doctrine
# as the learnings cache).
class GhCloner
# `gh repo clone` failed (gh missing, unauthenticated, or a git error).
class CloneFailedError < RuntimeError; end

# Thin wrapper over the gh CLI boundary. Tests inject a fake.
class Executor
# Run argv streaming its output, with the child's stdout redirected
# to stderr: clone progress belongs with diagnostics, and `dev clone`'s
# stdout is reserved for the machine payload (the destination path the
# shell wrapper cds into).
#
# @param argv [Array<String>]
# @return [Boolean] whether the command exited 0
def system(*argv)
Kernel.system(*argv, out: $stderr) ? true : false
end
end

# @param executor [Executor] CLI boundary (injectable for tests)
def initialize(executor: Executor.new)
@executor = executor
end

# Clone full_name into destination, creating parent directories first
# (the canonical layout's host/org levels may not exist yet).
#
# @param full_name [String] "owner/repo"
# @param destination [Pathname] the target checkout directory
# @return [void]
# @raise [CloneFailedError] when the clone exits non-zero
def clone(full_name, destination)
FileUtils.mkdir_p(destination.dirname)
return if @executor.system("gh", "repo", "clone", full_name, destination.to_s)

raise CloneFailedError, "gh repo clone #{full_name} failed — is gh authenticated? (gh auth login)"
end
end
end
end
71 changes: 71 additions & 0 deletions lib/dev/clone/repo_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

require "pathname"

module Dev
module Clone
# The parsed target of a `dev clone` invocation.
#
# Accepts "<repo>" (org defaults to d3mlabs) or "<org>/<repo>". The host
# is always github.com — clones ride the user's gh auth, and the
# canonical checkout layout under the search root is host/org/repo.
#
# A plain value class rather than Data.define: constants declared inside
# a define block land on the enclosing module (breaking the nested typed
# error), and Sorbet rejects the `class X < Data.define` form.
class RepoSpec
# The argument is not a "<repo>" or "<org>/<repo>" clone target.
class MalformedRepoError < RuntimeError; end

DEFAULT_ORG = "d3mlabs"
HOST = "github.com"

# GitHub owner/repo name characters: word chars, dots, hyphens.
SEGMENT_PATTERN = /\A[\w.-]+\z/

# @return [String]
attr_reader :org, :name

class << self
# Parse a clone target argument into a spec.
#
# @param arg [String] "<repo>" or "<org>/<repo>"
# @return [Dev::Clone::RepoSpec]
# @raise [MalformedRepoError] when the argument is not one or two
# valid path segments
def parse(arg)
# -1 keeps trailing empty segments, so "repo/" fails validation
# instead of silently collapsing to "repo".
segments = arg.split("/", -1)
unless (1..2).cover?(segments.size) && segments.all? { |segment| segment.match?(SEGMENT_PATTERN) }
raise MalformedRepoError, "expected <repo> or <org>/<repo>, got '#{arg}'"
end

org, name = segments.size == 2 ? segments : [DEFAULT_ORG, segments.fetch(0)]
new(org:, name:)
end
end

# @param org [String] the GitHub owner
# @param name [String] the repo name
def initialize(org:, name:)
@org = org
@name = name
end

# The gh clone target.
#
# @return [String] "org/repo"
def full_name
"#{org}/#{name}"
end

# The canonical checkout location relative to the search root.
#
# @return [Pathname] "github.com/<org>/<repo>"
def relative_path
Pathname(HOST) / org / name
end
end
end
end
Loading
Loading