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
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.1.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0" />
<PackageVersion Include="TUnit" Version="1.2.11" />
</ItemGroup>
</Project>
</Project>
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ All functions support switching between Docker image variants using flags:
- **`-h` or `--help`** - Show usage help and examples
- **`--no-cleanup`** - Skip cleanup of unused Docker images
- **`--no-pull`** - Skip pulling the latest image
- **`--mount <path>`** - Mount a directory as read-only
- **`--mount-rw <path>`** - Mount a directory as read-write
- **`--mount <path>`** - Mount a directory as read-only (supports `path` or `host:container` format)
- **`--mount-rw <path>`** - Mount a directory as read-write (supports `path` or `host:container` format)
- **`--save-mount <path>`** - Save a mount to local config
- **`--save-mount-global <path>`** - Save a mount to global config
- **`--remove-mount <path>`** - Remove a saved mount
Expand All @@ -78,8 +78,10 @@ These options are passed directly to the GitHub Copilot CLI:
By default, `copilot_here` only mounts the current working directory. You can mount additional directories using flags or configuration files.

**CLI Flags:**
- `--mount ./path/to/dir` (Read-only)
- `--mount-rw ./path/to/dir` (Read-write)
- `--mount ./path/to/dir` (Read-only, auto-computed container path)
- `--mount-rw ./path/to/dir` (Read-write, auto-computed container path)
- `--mount /host/path:/container/path` (Read-only, custom container path)
- `--mount-rw /host/path:/container/path` (Read-write, custom container path)

**Configuration Files:**
- Global: `~/.config/copilot_here/mounts.conf`
Expand Down
4 changes: 2 additions & 2 deletions app/Commands/Mounts/ListMounts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ private static Command SetListMountsCommand()
foreach (var mount in config.GlobalMounts)
{
var mode = mount.IsReadWrite ? "rw" : "ro";
Console.WriteLine($" 🌍 {mount.Path} ({mode})");
Console.WriteLine($" 🌍 {mount.HostPath} ({mode})");
}

foreach (var mount in config.LocalMounts)
{
var mode = mount.IsReadWrite ? "rw" : "ro";
Console.WriteLine($" 📍 {mount.Path} ({mode})");
Console.WriteLine($" 📍 {mount.HostPath} ({mode})");
}

Console.WriteLine();
Expand Down
87 changes: 75 additions & 12 deletions app/Commands/Mounts/_MountsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,32 @@
foreach (var line in ConfigFile.ReadLines(path))
{
var isReadWrite = false;
var mountPath = line;
var workingLine = line;

if (line.EndsWith(":rw", StringComparison.OrdinalIgnoreCase))
// Check for :rw or :ro suffix
if (workingLine.EndsWith(":rw", StringComparison.OrdinalIgnoreCase))
{
isReadWrite = true;
mountPath = line[..^3];
workingLine = workingLine[..^3];
}
else if (line.EndsWith(":ro", StringComparison.OrdinalIgnoreCase))
else if (workingLine.EndsWith(":ro", StringComparison.OrdinalIgnoreCase))
{
mountPath = line[..^3];
workingLine = workingLine[..^3];
}

mounts.Add(new MountEntry(mountPath, isReadWrite, source));
// Parse hostPath:containerPath format
var colonIndex = workingLine.IndexOf(':');
if (colonIndex > 0)
{
var hostPath = workingLine[..colonIndex];
var containerPath = workingLine[(colonIndex + 1)..];
mounts.Add(new MountEntry(hostPath, containerPath, isReadWrite, source));
}
else
{
var hostPath = workingLine;
mounts.Add(new MountEntry(hostPath, null, isReadWrite, source));

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test Airlock

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (ubuntu-24.04)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (ubuntu-24.04)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (macos-15-intel)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Test CLI (macos-15-intel)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Shell Functions (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Shell Functions (macos-15-intel)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Integration Tests (macos-15-intel)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Integration Tests (ubuntu-24.04)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Shell Functions (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Integration Tests (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 63 in app/Commands/Mounts/_MountsConfig.cs

View workflow job for this annotation

GitHub Actions / Shell Functions (ubuntu-24.04)

Cannot convert null literal to non-nullable reference type.
}
}

return mounts;
Expand Down Expand Up @@ -133,14 +146,35 @@
/// <summary>
/// A single mount entry with its path, mode, and source.
/// </summary>
public readonly record struct MountEntry(string Path, bool IsReadWrite, MountSource Source)
public readonly record struct MountEntry
{
/// <summary>
/// A single mount entry with its path, mode, and source.
/// </summary>
public MountEntry(string HostPath, bool IsReadWrite, MountSource Source)
{
this.HostPath = HostPath;
this.IsReadWrite = IsReadWrite;
this.Source = Source;
}

/// <summary>
/// A single mount entry with its host/container paths, mode, and source.
/// </summary>
public MountEntry(string HostPath, string ContainerPath, bool IsReadWrite, MountSource Source)
{
this.HostPath = HostPath;
this.ContainerPath = ContainerPath;
this.IsReadWrite = IsReadWrite;
this.Source = Source;
}

/// <summary>
/// Resolves ~ and relative paths to absolute paths, following symlinks.
/// </summary>
public string ResolvePath(string userHome)
public string ResolveHostPath(string userHome)
{
return PathValidator.ResolvePath(Path, userHome);
return PathValidator.ResolvePath(HostPath, userHome);
}

/// <summary>
Expand All @@ -149,7 +183,7 @@
/// </summary>
public bool Validate(string userHome)
{
var resolved = ResolvePath(userHome);
var resolved = ResolveHostPath(userHome);

// Warn if path doesn't exist
PathValidator.WarnIfNotExists(resolved);
Expand All @@ -166,7 +200,13 @@
/// <summary>Calculates the container path for this mount.</summary>
public string GetContainerPath(string userHome)
{
var hostPath = ResolvePath(userHome);
// If custom container path is specified, expand tilde to container user home
if (!string.IsNullOrWhiteSpace(ContainerPath))
{
return ExpandContainerTilde(ContainerPath);
}

var hostPath = ResolveHostPath(userHome);
if (hostPath.StartsWith(userHome))
{
return $"/home/appuser/{System.IO.Path.GetRelativePath(userHome, hostPath).Replace("\\", "/")}";
Expand All @@ -185,10 +225,28 @@
return $"/work{hostPath}";
}

/// <summary>
/// Expands tilde in container paths to /home/appuser (the container user's home).
/// </summary>
private static string ExpandContainerTilde(string containerPath)
{
if (containerPath == "~")
{
return "/home/appuser";
}

if (containerPath.StartsWith("~/"))
{
return $"/home/appuser/{containerPath.Substring(2)}";
}

return containerPath;
}

/// <summary>Gets the Docker volume mount string.</summary>
public string ToDockerVolume(string userHome)
{
var hostPath = ResolvePath(userHome);
var hostPath = ResolveHostPath(userHome);
var dockerHostPath = ConvertToDockerPath(hostPath);
var containerPath = GetContainerPath(userHome);
var mode = IsReadWrite ? "rw" : "ro";
Expand Down Expand Up @@ -217,6 +275,11 @@

return normalizedPath;
}

public string HostPath { get; init; }
public bool IsReadWrite { get; init; }
public MountSource Source { get; init; }
public string? ContainerPath { get; init; }
}

public enum MountSource
Expand Down
59 changes: 47 additions & 12 deletions app/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ public RunCommand(bool isYolo = false)

_golangOption = new Option<bool>("--golang") { Description = "[-go] Use Golang image variant" };

_mountOption = new Option<string[]>("--mount") { Description = "Mount additional directory (read-only)" };
_mountOption = new Option<string[]>("--mount") { Description = "Mount directory (read-only). Format: path or host:container" };

_mountRwOption = new Option<string[]>("--mount-rw") { Description = "Mount additional directory (read-write)" };
_mountRwOption = new Option<string[]>("--mount-rw") { Description = "Mount directory (read-write). Format: path or host:container" };

_noCleanupOption = new Option<bool>("--no-cleanup") { Description = "Skip cleanup of unused Docker images" };

Expand Down Expand Up @@ -447,6 +447,7 @@ public void Configure(RootCommand root)
var allMounts = new List<MountEntry>();

// Parse CLI mounts first (highest priority)
// Supports both simple paths and host:container format
foreach (var path in cliMountsRo)
allMounts.Add(ParseCliMount(path, defaultReadWrite: false));
foreach (var path in cliMountsRw)
Expand All @@ -469,7 +470,7 @@ public void Configure(RootCommand root)
else
{
// User cancelled due to sensitive path - skip this mount
Console.WriteLine($"⏭️ Skipping mount: {mount.Path}");
Console.WriteLine($"⏭️ Skipping mount: {mount.HostPath}");
}
}
allMounts = validatedMounts;
Expand Down Expand Up @@ -622,26 +623,60 @@ private static List<string> BuildDockerArgs(
}

/// <summary>
/// Parses a CLI mount path, handling :rw/:ro suffix.
/// Format: "path" or "path:rw" or "path:ro"
/// Parses a CLI mount path, handling both simple paths and host:container format.
/// Format: "path", "path:rw", "path:ro", "host:container", "host:container:rw", "host:container:ro"
/// </summary>
private static MountEntry ParseCliMount(string input, bool defaultReadWrite)
internal static MountEntry ParseCliMount(string input, bool defaultReadWrite)
{
var isReadWrite = defaultReadWrite;
var mountPath = input;
var spec = input.Trim('\'', '"'); // Remove any surrounding quotes

if (input.EndsWith(":rw", StringComparison.OrdinalIgnoreCase))
// Check for trailing :rw or :ro
if (spec.EndsWith(":rw", StringComparison.OrdinalIgnoreCase))
{
isReadWrite = true;
mountPath = input[..^3];
spec = spec[..^3];
}
else if (input.EndsWith(":ro", StringComparison.OrdinalIgnoreCase))
else if (spec.EndsWith(":ro", StringComparison.OrdinalIgnoreCase))
{
isReadWrite = false;
mountPath = input[..^3];
spec = spec[..^3];
}

return new MountEntry(mountPath, isReadWrite, MountSource.CommandLine);
// Check if this is a host:container format
var separatorIndex = FindBindSeparator(spec);

if (separatorIndex == -1)
{
// Simple path format - auto-compute container path
return new MountEntry(spec, isReadWrite, MountSource.CommandLine);
}

// host:container format
var hostPath = spec[..separatorIndex];
var containerPath = spec[(separatorIndex + 1)..];

return new MountEntry(hostPath, containerPath, isReadWrite, MountSource.CommandLine);
}

/// <summary>
/// Finds the separator between host and container paths in a bind spec.
/// On Windows, skips the colon after drive letter (e.g., C:\path).
/// </summary>
private static int FindBindSeparator(string bindSpec)
{
var startIndex = 0;

// On Windows, skip past drive letter colon (e.g., C:)
if (OperatingSystem.IsWindows() &&
bindSpec.Length >= 2 &&
char.IsLetter(bindSpec[0]) &&
bindSpec[1] == ':')
{
startIndex = 2;
}

return bindSpec.IndexOf(':', startIndex);
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion app/Infrastructure/AirlockRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath)
{
var mode = mount.IsReadWrite ? "rw" : "ro";
var containerPath = mount.GetContainerPath(ctx.Paths.UserHome);
var resolvedPath = mount.ResolvePath(ctx.Paths.UserHome);
var resolvedPath = mount.ResolveHostPath(ctx.Paths.UserHome);
var dockerPath = ConvertToDockerPath(resolvedPath);
extraMounts.AppendLine($" - {dockerPath}:{containerPath}:{mode}");
}
Expand Down
2 changes: 1 addition & 1 deletion app/Infrastructure/SessionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static string Generate(
var m = mounts[i];
if (i > 0) json.Append(',');
json.Append('{');
json.Append($"\"host_path\":\"{JsonEncode(m.ResolvePath(ctx.Paths.UserHome))}\",");
json.Append($"\"host_path\":\"{JsonEncode(m.ResolveHostPath(ctx.Paths.UserHome))}\",");
json.Append($"\"container_path\":\"{JsonEncode(m.GetContainerPath(ctx.Paths.UserHome))}\",");
json.Append($"\"mode\":\"{(m.IsReadWrite ? "rw" : "ro")}\",");
json.Append($"\"source\":\"{m.Source.ToString().ToLowerInvariant()}\"");
Expand Down
1 change: 1 addition & 0 deletions tests/CopilotHere.UnitTests/CopilotHere.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Testing.Platform.MSBuild" />
<PackageReference Include="TUnit" />
</ItemGroup>

Expand Down
Loading