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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ This backfills your past sessions from `~/.claude/projects/` (Claude), `~/.codex

You must pick an explicit scope (`--all`, `--org`, or `--repo`) so personal/private repos aren't uploaded by accident. `--org` uses the active profile name as the GitHub org login — it works out of the box when the profile was created by `kcap setup` (which names it after the picked tenant), and errors otherwise. Run with no scope on an interactive terminal to get a picker. See [Loading historical sessions](#loading-historical-sessions) for the full set of flags.

If your repo directories have been renamed or deleted on disk, the import prints a list of unresolved cwds up front. See [Renamed repo directories (`cwd_remap`)](#renamed-repo-directories-cwd_remap) to recover those sessions.

### 4. Open the dashboard

Open the server URL in your browser. The dashboard shows repositories, sessions, and agents. It updates in real time as Claude Code sessions are active.
Expand Down Expand Up @@ -285,6 +287,8 @@ kcap import --org --session abc123 # single session

Non-interactive runs (no TTY, e.g. CI) must pass both a scope flag and `--yes`. The command is idempotent and resumable — re-running with the same scope only uploads what's missing or incomplete. A server-side tracker deduplicates events on `(stream, eventId)` so previously-imported turns don't get re-appended.

After discovery, the import surfaces a one-shot report of any transcript working directories that no longer exist on disk (typically deleted worktrees, or local repo dirs that have been renamed). Those sessions won't match an `--org` / `--repo` scope until you tell kcap how their old paths map to the new ones. See [Renamed repo directories (`cwd_remap`)](#renamed-repo-directories-cwd_remap) below for the fix.

### Daemon

The daemon connects to the Capacitor server and runs Claude Code or Codex agents in isolated git worktrees, controlled from the dashboard. The daemon supports hosted Claude and Codex agents on macOS and Linux — choose the vendor from the dashboard's launch dialog. At startup the daemon probes `daemon.claude_path` and `daemon.codex_path` and advertises only the vendors it can actually spawn, so the launch dialog hides whichever agent isn't installed on the selected daemon.
Expand Down Expand Up @@ -461,6 +465,33 @@ kcap ignore --remove ~/code/secret-project

Entries are stored on the **active profile**, so switching profiles with `kcap use` switches the ignore list too. Symlinks are resolved on both the stored entry and the session's reported cwd, so a worktree symlink and its target match.

#### Renamed repo directories (`cwd_remap`)

Historic transcripts record the absolute working directory they ran in. If you've since renamed or moved that directory on disk (e.g. `~/dev/foo-cli → ~/dev/bar-cli`), `kcap import --org` / `--repo` can't resolve those sessions to a GitHub repo any more and silently drops them from the matched count.

To recover them, add `cwd_remap` to `~/.config/kcap/config.json` (no `kcap config set` shortcut — edit the file directly):

```json
{
"version": 2,
"active_profile": "default",
"profiles": { "default": { /* ... */ } },
"cwd_remap": [
{ "from": "~/dev/eventstore/foo-cli", "to": "~/dev/eventstore/bar-cli" },
{ "from": "~/dev/eventstore/foo", "to": "~/dev/eventstore/bar" }
]
}
```

Semantics:

- `from` / `to` are **path-prefix** rewrites with `~` expanding to the current user's home directory. The match requires a path boundary (`from` exactly equal, or `from` followed by `/`), so `from: "~/dev/foo"` will **not** spuriously rewrite `~/dev/foo-cli`.
- When multiple rules could apply to the same transcript cwd, the **longest** `from` wins.
- Rules are applied once (no chaining), so the result of one rule isn't fed into another.
- Remaps are global, not per-profile — same rename affects all profiles' imports.

After editing, re-run `kcap import --org` (or whatever scope you use). The missing-cwd report at the top of the import will show what's still unresolved.

### Uninstalling

To remove kcap from this machine, run:
Expand Down
19 changes: 19 additions & 0 deletions src/Capacitor.Cli.Core/Config/ProfileConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ public record ProfileConfig {

[JsonPropertyName("profile_bindings")]
public Dictionary<string, string> ProfileBindings { get; init; } = new();

/// <summary>
/// Path-prefix remaps applied to historic transcript cwds before repository
/// detection. Useful when a local repo directory has been renamed (e.g.
/// <c>~/dev/kapacitor-cli → ~/dev/kcap-cli</c>) so old sessions can still
/// be resolved to their org/repo during <c>kcap import</c>.
/// Match is a path-boundary prefix (cwd == from || cwd starts with from + "/");
/// longest-from wins when multiple rules could apply.
/// </summary>
[JsonPropertyName("cwd_remap")]
public CwdRemap[] CwdRemap { get; init; } = [];
}

public record CwdRemap {
[JsonPropertyName("from")]
public string From { get; init; } = "";

[JsonPropertyName("to")]
public string To { get; init; } = "";
}

public record Profile {
Expand Down
145 changes: 138 additions & 7 deletions src/Capacitor.Cli/Commands/ImportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,13 @@ await Console.Error.WriteLineAsync(

// Run file-based repo resolution. The helper takes a single codex bool;
// since Claude and Codex differ only in cwd-extraction, run it per
// vendor and merge.
var resolved = new Dictionary<string, (string Owner, string Name)?>(StringComparer.Ordinal);
// vendor and merge. User-configured cwd remaps let historic transcripts
// referencing since-renamed local repo paths still resolve to a real
// git directory.
var profileConfig = await AppConfig.LoadProfileConfig();
var cwdRemap = profileConfig.CwdRemap;
var resolved = new Dictionary<string, (string Owner, string Name)?>(StringComparer.Ordinal);
var sessionCwds = new Dictionary<string, string>(StringComparer.Ordinal);

foreach (var vendor in new[] { "claude", "codex" }) {
var slice = allFileTuples
Expand All @@ -525,14 +530,26 @@ await Console.Error.WriteLineAsync(

if (slice.Count == 0) continue;

var partial = await ResolveTranscriptReposAsync(slice, codex: vendor == "codex", display);
var partial = await ResolveTranscriptReposAsync(slice, codex: vendor == "codex", display, cwdRemap, sessionCwds);
foreach (var kv in partial) resolved[kv.Key] = kv.Value;
}

// Resolve Cursor sessions in parallel by workspace path (dedup like
// ResolveTranscriptReposAsync).
if (cursorCwds.Count > 0) {
var uniqueWorkspaces = cursorCwds.Values
// Remap once per session so repo detection AND the missing-cwd
// report below see the same path.
var cursorRemapped = cursorCwds.ToDictionary(
kv => kv.Key,
kv => kv.Value is null ? null : CwdRemapper.Apply(kv.Value, cwdRemap),
StringComparer.Ordinal
);

foreach (var (sid, cwd) in cursorRemapped) {
if (cwd is not null) sessionCwds[sid] = cwd;
}

var uniqueWorkspaces = cursorRemapped.Values
.Where(c => c is not null)
.Cast<string>()
.Distinct(StringComparer.Ordinal)
Expand All @@ -557,11 +574,17 @@ await Parallel.ForEachAsync(
);
}

foreach (var (sid, cwd) in cursorCwds) {
foreach (var (sid, cwd) in cursorRemapped) {
resolved[sid] = cwd is not null && repoByCwd.TryGetValue(cwd, out var r) ? r : null;
}
}

// --- Missing cwd report ---
// Surface transcripts whose (possibly remapped) cwd no longer exists on
// disk so the user understands why some sessions won't match an org or
// repo scope, and can fix it by adding cwd_remap entries.
ReportMissingCwds(sessionCwds, cwdRemap, display);

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
// --- Scope picker ---
var kcapConfig = await AppConfig.Load();

Expand Down Expand Up @@ -1609,16 +1632,124 @@ internal sealed record CodexGitInfo(string? RemoteUrl, string? Branch, string? C
/// parallel, surfacing progress via a Spectre status spinner on a TTY and
/// a plain status line otherwise.
/// </summary>
/// <summary>
/// Print a one-shot summary of transcript cwds that don't exist on disk.
/// Most users with a long history accumulate references to deleted
/// worktrees and renamed repo directories, and those sessions silently
/// fail to match an --org/--repo scope. This output lets them spot the
/// gap before the import proceeds.
/// </summary>
internal static void ReportMissingCwds(
IReadOnlyDictionary<string, string> sessionCwds,
IReadOnlyList<CwdRemap>? cwdRemap,
ImportDisplay display
) {
if (sessionCwds.Count == 0) return;

var uniqueCwds = sessionCwds.Values.Distinct(StringComparer.Ordinal).ToList();
var missing = uniqueCwds.Where(c => !Directory.Exists(c)).ToHashSet(StringComparer.Ordinal);

if (missing.Count == 0) return;

// Collapse descendants under missing ancestors — if /repo is missing,
// there's no value in also listing /repo/.claude/worktrees/agent-X.
var roots = CollapseDescendants(missing);

var sessionsAffected = sessionCwds.Values.Count(missing.Contains);
var sortedRoots = roots.OrderBy(c => c, StringComparer.Ordinal).ToList();
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);

var sessionWord = sessionsAffected == 1 ? "session references" : "sessions reference";
var pathWord = sortedRoots.Count == 1 ? "path that no longer exists" : "distinct paths that no longer exist";
display.Line($"{sessionsAffected} {sessionWord} {sortedRoots.Count} {pathWord} on disk:");

const int sampleSize = 5;
foreach (var cwd in sortedRoots.Take(sampleSize)) display.Line($" {ShortenHome(cwd, home)}");

if (sortedRoots.Count > sampleSize) {
display.Line($" ... and {sortedRoots.Count - sampleSize} more");
}

var hint = cwdRemap is { Count: > 0 }
? "Update the cwd_remap entries in your kcap config to map these to their new on-disk paths."
: "Add cwd_remap entries to your kcap config to map these to their new on-disk paths.";

display.Line(hint);
}

/// <summary>
/// Drop any path from <paramref name="paths"/> whose parent (at any depth)
/// is also in the set. Used to collapse worktree-style descendants like
/// <c>/repo/.claude/worktrees/agent-X</c> under their already-missing
/// parent <c>/repo</c> for a less noisy report.
/// </summary>
internal static HashSet<string> CollapseDescendants(IReadOnlySet<string> paths) {
var roots = new HashSet<string>(StringComparer.Ordinal);

foreach (var p in paths) {
if (!HasAncestorIn(p, paths)) roots.Add(p);
}

return roots;

static bool HasAncestorIn(string path, IReadOnlySet<string> set) {
// Walk parent directories: /a/b/c → /a/b → /a → / (stop at root or
// when GetDirectoryName returns null/empty).
var parent = Path.GetDirectoryName(path);

while (!string.IsNullOrEmpty(parent) && parent != path) {
if (set.Contains(parent)) return true;
path = parent;
parent = Path.GetDirectoryName(parent);
}

return false;
}
}

/// <summary>
/// Replace the user's home directory prefix with <c>~</c> for display only.
/// Uses path-boundary matching (either <c>/</c> or <c>\</c>) so siblings
/// like <c>/Users/alexeyfoo</c> aren't accidentally shortened, and follows
/// the host filesystem's case-sensitivity policy.
/// </summary>
internal static string ShortenHome(string path, string home) {
var comparison = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

if (string.IsNullOrEmpty(home) || !path.StartsWith(home, comparison)) return path;
if (path.Length == home.Length) return "~";
return CwdRemapper.IsSeparator(path[home.Length]) ? "~" + path[home.Length..] : path;
}

static async Task<Dictionary<string, (string Owner, string Name)?>> ResolveTranscriptReposAsync(
IReadOnlyList<(string SessionId, string FilePath, string EncodedCwd)> transcripts,
bool codex,
ImportDisplay display
ImportDisplay display,
IReadOnlyList<CwdRemap>? cwdRemap = null,
IDictionary<string, string>? sessionCwds = null
) {
// Extract cwd per transcript first (cheap: ≤20-line file read).
// Apply user-configured prefix remaps so historic transcripts pointing
// at since-renamed local directories still resolve. The per-session
// (remapped) cwd is also fed back to the caller via sessionCwds so the
// import flow can report which paths are missing on disk.
var perTranscript = new (string SessionId, string? Cwd)[transcripts.Count];

for (var i = 0; i < transcripts.Count; i++) {
perTranscript[i] = (transcripts[i].SessionId, ExtractCwdFromTranscript(transcripts[i].FilePath, codex));
var raw = ExtractCwdFromTranscript(transcripts[i].FilePath, codex);
var remapped = raw is null ? null : CwdRemapper.Apply(raw, cwdRemap);

perTranscript[i] = (transcripts[i].SessionId, remapped);

// Indexer assignment (not Add) so duplicate SessionIds across
// project dirs / backups can't abort the import. Last-write-wins
// is fine for the missing-cwd report — the cwd is functionally
// the same path anyway.
if (remapped is not null && sessionCwds is not null) {
sessionCwds[transcripts[i].SessionId] = remapped;
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

var uniqueCwds = perTranscript
Expand Down
85 changes: 85 additions & 0 deletions src/Capacitor.Cli/CwdRemapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Runtime.InteropServices;
using Capacitor.Cli.Core.Config;

namespace Capacitor.Cli;

/// <summary>
/// Applies user-configured path-prefix remaps to a transcript cwd so historic
/// sessions that recorded a since-renamed local repo path (e.g.
/// <c>~/dev/kapacitor-cli</c> after rename to <c>~/dev/kcap-cli</c>) can still
/// be matched to an on-disk git repository during <c>kcap import</c>.
///
/// Matching rules:
/// - <c>~</c> or <c>~/</c> (or <c>~\</c> on Windows) at the start of
/// <c>from</c>/<c>to</c> is expanded to the current user's home directory
/// (transcript cwds are absolute).
/// - Path-boundary prefix: <c>cwd == from</c> or <c>cwd starts with from + sep</c>
/// where <c>sep</c> is either <c>/</c> or <c>\</c>; so <c>/dev/kapacitor</c>
/// does NOT spuriously match <c>/dev/kapacitor-cli</c>.
/// - Comparisons are case-insensitive on Windows, case-sensitive elsewhere —
/// matching the host filesystem's behavior.
/// - Longest <c>from</c> wins when multiple rules could apply.
/// - At most one remap is applied (no chaining), to keep behavior predictable.
/// </summary>
static class CwdRemapper {
static readonly StringComparison PathComparison =
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

public static string Apply(string cwd, IReadOnlyList<CwdRemap>? rules) =>
Apply(cwd, rules, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));

// Internal seam so tests can pin the home directory and stay cross-platform.
internal static string Apply(string cwd, IReadOnlyList<CwdRemap>? rules, string home) =>
Apply(cwd, rules, home, PathComparison);

// Internal seam for tests that need to pin the comparison policy (so the
// case-sensitivity behavior can be exercised regardless of host OS).
internal static string Apply(string cwd, IReadOnlyList<CwdRemap>? rules, string home, StringComparison comparison) {
if (rules is null or { Count: 0 } || string.IsNullOrEmpty(cwd)) return cwd;

CwdRemap? best = null;
string? bestFrom = null;
string? bestTo = null;
var bestLen = -1;

foreach (var rule in rules) {
if (string.IsNullOrEmpty(rule.From) || rule.To is null) continue;

var from = ExpandHome(rule.From, home);

if (!IsPrefixMatch(cwd, from, comparison)) continue;

if (from.Length > bestLen) {
best = rule;
bestFrom = from;
bestTo = ExpandHome(rule.To, home);
bestLen = from.Length;
}
}

if (best is null) return cwd;

// cwd == from → use 'to' verbatim; otherwise replace prefix + keep the
// tail (the leading separator of the tail was at position from.Length).
return cwd.Length == bestFrom!.Length
? bestTo!
: bestTo + cwd[bestFrom.Length..];
}

static string ExpandHome(string path, string home) {
if (path.Length == 0 || path[0] != '~') return path;
if (path.Length == 1) return home; // "~"
if (IsSeparator(path[1])) return home + path[1..]; // "~/foo" or "~\foo"
return path; // "~user" / "~foo" — leave alone
}

static bool IsPrefixMatch(string cwd, string from, StringComparison comparison) {
if (!cwd.StartsWith(from, comparison)) return false;
if (cwd.Length == from.Length) return true;
return IsSeparator(cwd[from.Length]);
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

internal static bool IsSeparator(char c) => c == '/' || c == '\\';
}
Loading
Loading