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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `DependencyResolver.swift` — topological sort of component dependencies with cycle detection

### External Pack System (`Sources/mcs/ExternalPack/`)
- `ExternalPackManifest.swift` — YAML `techpack.yaml` schema (Codable models for components, templates, hooks, doctor checks, configure scripts). Supports **shorthand syntax** (`brew:`, `mcp:`, `plugin:`, `hook:`, `command:`, `skill:`, `agent:`, `settingsFile:`, `gitignore:`, `shell:`) that infers `type` + `installAction` from a single key
- `ExternalPackManifest.swift` — YAML `techpack.yaml` schema (Codable models for components, templates, hooks, doctor checks, configure scripts). Supports **shorthand syntax** (`brew:`, `mcp:`, `plugin:`, `hook:`, `command:`, `skill:`, `agent:`, `settingsFile:`, `gitignore:`, `shell:`) that infers `type` + `installAction` from a single key. `shellInteractive: true` alongside `shell:` allocates a PTY for commands needing terminal access (e.g. `sudo`)
- `ExternalPackAdapter.swift` — bridges `ExternalPackManifest` to the `TechPack` protocol
- `ExternalPackLoader.swift` — discovers and loads packs from `~/.mcs/packs/` (git) or absolute paths (local)
- `PackFetcher.swift` — Git clone/pull for pack repositories
Expand Down
203 changes: 190 additions & 13 deletions Sources/mcs/Core/ShellRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ protocol ShellRunning: Sendable {
_ executable: String,
arguments: [String],
workingDirectory: String?,
additionalEnvironment: [String: String]
additionalEnvironment: [String: String],
interactive: Bool
) -> ShellResult

/// Run a shell command string via /bin/bash -c.
@discardableResult
func shell(
_ command: String,
workingDirectory: String?,
additionalEnvironment: [String: String]
additionalEnvironment: [String: String],
interactive: Bool
) -> ShellResult
}

Expand All @@ -45,18 +47,26 @@ extension ShellRunning {
_ executable: String,
arguments: [String] = [],
workingDirectory: String? = nil,
additionalEnvironment: [String: String] = [:]
additionalEnvironment: [String: String] = [:],
interactive: Bool = false
) -> ShellResult {
run(executable, arguments: arguments, workingDirectory: workingDirectory, additionalEnvironment: additionalEnvironment)
run(
executable, arguments: arguments, workingDirectory: workingDirectory,
additionalEnvironment: additionalEnvironment, interactive: interactive
)
}

@discardableResult
func shell(
_ command: String,
workingDirectory: String? = nil,
additionalEnvironment: [String: String] = [:]
additionalEnvironment: [String: String] = [:],
interactive: Bool = false
) -> ShellResult {
shell(command, workingDirectory: workingDirectory, additionalEnvironment: additionalEnvironment)
shell(
command, workingDirectory: workingDirectory,
additionalEnvironment: additionalEnvironment, interactive: interactive
)
}
}

Expand All @@ -71,22 +81,36 @@ struct ShellRunner: ShellRunning {
}

/// Run an executable with arguments, capturing stdout and stderr.
///
/// - Parameter interactive: When `true`, uses `forkpty()` to allocate a
/// real pseudo-terminal so commands like `sudo` can prompt for passwords
/// securely. Output goes directly to the terminal (not captured); the
/// returned `ShellResult` will have empty `stdout` and `stderr`.
/// Defaults to `false` (stdin `/dev/null`, stdout/stderr piped).
@discardableResult
func run(
_ executable: String,
arguments: [String],
workingDirectory: String?,
additionalEnvironment: [String: String]
additionalEnvironment: [String: String],
interactive: Bool
) -> ShellResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments

var env = ProcessInfo.processInfo.environment
env["PATH"] = environment.pathWithBrew
for (key, value) in additionalEnvironment {
env[key] = value
}

if interactive {
return runInteractive(
executable: executable, arguments: arguments,
environment: env, workingDirectory: workingDirectory
)
}

let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.environment = env

if let cwd = workingDirectory {
Expand Down Expand Up @@ -133,13 +157,166 @@ struct ShellRunner: ShellRunning {
func shell(
_ command: String,
workingDirectory: String?,
additionalEnvironment: [String: String]
additionalEnvironment: [String: String],
interactive: Bool
) -> ShellResult {
run(
Constants.CLI.bash,
arguments: ["-c", command],
workingDirectory: workingDirectory,
additionalEnvironment: additionalEnvironment
additionalEnvironment: additionalEnvironment,
interactive: interactive
)
}

/// Run a command with a real pseudo-terminal using `forkpty()`.
///
/// `Foundation.Process` never allocates a PTY, so commands like `sudo`
/// that require `isatty() == true` fail with "unable to read password".
/// This method uses `forkpty()` to create a real PTY pair and then
/// bridges I/O between the parent terminal and the child's PTY so that
/// `sudo` can properly disable echo and read passwords securely.
/// The returned `ShellResult` has empty `stdout` and `stderr` since
/// output goes directly to the terminal.
private func runInteractive(
executable: String,
arguments: [String],
environment env: [String: String],
workingDirectory: String?
) -> ShellResult {
// Prepare environment and argv as C strings for execve().
let envp: [UnsafeMutablePointer<CChar>?] = env.map { key, value in
strdup("\(key)=\(value)")
} + [nil]
defer { envp.compactMap(\.self).forEach { free($0) } }

let argv: [UnsafeMutablePointer<CChar>?] = ([executable] + arguments).map { strdup($0) } + [nil]
defer { argv.compactMap(\.self).forEach { free($0) } }

// Pre-convert workingDirectory to a C string before fork so the child
// doesn't need to invoke Swift's String-to-CString bridge (not fork-safe).
let cwdCStr = workingDirectory.map { strdup($0) }
defer { cwdCStr.map { free($0) } }

// Save the terminal's current attributes so we can restore them after.
var originalTermios = termios()
let hasTerminal = tcgetattr(STDIN_FILENO, &originalTermios) == 0

// forkpty() creates a PTY pair and forks.
// Parent gets the PTY fd; child gets stdin/stdout/stderr on the other end.
var ptyFD: Int32 = -1
let pid = forkpty(&ptyFD, nil, nil, nil)

if pid == -1 {
return ShellResult(exitCode: 1, stdout: "", stderr: "forkpty failed: \(String(cString: strerror(errno)))")
}

if pid == 0 {
// ── Child process ──
// After fork, only async-signal-safe functions are safe. Avoid Swift
// runtime calls (String interpolation, ARC) — they can deadlock on
// locks held by other threads in the parent at fork time.
if let cwd = cwdCStr {
if chdir(cwd) != 0 {
let err = strerror(errno)
_ = write(STDERR_FILENO, "chdir failed: ", 14)
if let err { _ = write(STDERR_FILENO, err, strlen(err)) }
_ = write(STDERR_FILENO, "\n", 1)
_exit(126)
}
}
// Use argv[0] (already a C string from strdup) instead of the Swift String.
execve(argv[0], argv, envp)
let err = strerror(errno)
_ = write(STDERR_FILENO, "execve failed: ", 15)
if let err { _ = write(STDERR_FILENO, err, strlen(err)) }
_ = write(STDERR_FILENO, "\n", 1)
_exit(127)
}

// ── Parent process ──
// Put terminal in raw-ish mode so keystrokes (including Enter for sudo)
// are forwarded immediately without local echo interference, while still
// allowing terminal-generated signals like Ctrl-C / Ctrl-Z.
if hasTerminal {
var raw = originalTermios
cfmakeraw(&raw)
raw.c_lflag |= tcflag_t(ISIG)
tcsetattr(STDIN_FILENO, TCSANOW, &raw)
}

// Ensure terminal is restored even if we exit early or the process is interrupted.
defer {
if hasTerminal {
tcsetattr(STDIN_FILENO, TCSADRAIN, &originalTermios)
}
close(ptyFD)
}

// Bridge I/O between the real terminal and the PTY using poll().
var fds: [pollfd] = [
pollfd(fd: STDIN_FILENO, events: Int16(POLLIN), revents: 0),
pollfd(fd: ptyFD, events: Int16(POLLIN), revents: 0),
]
var buf = [UInt8](repeating: 0, count: 4096)

bridgeLoop: while true {
fds[0].revents = 0
fds[1].revents = 0

let ready = poll(&fds, nfds_t(fds.count), -1)
if ready < 0 {
if errno == EINTR { continue } // Retry on signal interruption
break
}

// Terminal → PTY (user typing, including password input)
if fds[0].revents & Int16(POLLIN) != 0 {
let n = read(STDIN_FILENO, &buf, buf.count)
if n <= 0 {
// stdin EOF — stop monitoring, let PTY drain remaining output
fds[0].fd = -1
} else {
writeAll(fd: ptyFD, buf: buf, count: n)
}
}
Comment thread
bguidolim marked this conversation as resolved.

// PTY → Terminal (command output, prompts, progress bars)
if fds[1].revents & Int16(POLLIN | POLLHUP) != 0 {
let n = read(ptyFD, &buf, buf.count)
if n <= 0 { break bridgeLoop } // Child closed the PTY
writeAll(fd: STDOUT_FILENO, buf: buf, count: n)
}
}

// Wait for the child and extract exit status.
// Equivalent to WIFEXITED/WEXITSTATUS — Swift doesn't expose wait status macros.
var status: Int32 = 0
while waitpid(pid, &status, 0) < 0 {
if errno != EINTR { break }
}
let exitCode: Int32 = if status & 0x7F == 0 {
(status >> 8) & 0xFF
} else {
// Killed by signal — report as 128 + signal (shell convention).
128 + (status & 0x7F)
}
Comment thread
bguidolim marked this conversation as resolved.

return ShellResult(exitCode: exitCode, stdout: "", stderr: "")
}

/// Write all bytes to a file descriptor, retrying on partial writes and EINTR.
private func writeAll(fd: Int32, buf: [UInt8], count: Int) {
buf.withUnsafeBufferPointer { ptr in
var offset = 0
while offset < count {
let n = write(fd, ptr.baseAddress! + offset, count - offset)
if n < 0 {
if errno == EINTR { continue }
break // EIO/EPIPE — fd closed
}
offset += n
}
}
}
}
5 changes: 4 additions & 1 deletion Sources/mcs/Export/ManifestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -532,9 +532,12 @@ struct ManifestBuilder {
yaml.line(" - \(yamlQuote(entry))")
}

case let .shellCommand(command):
case let .shellCommand(command, interactive):
yaml.line(" type: \(comp.type.rawValue)")
yaml.line(" shell: \(yamlQuote(command))")
if interactive {
yaml.line(" shellInteractive: true")
}

case .settingsMerge:
break
Expand Down
4 changes: 2 additions & 2 deletions Sources/mcs/ExternalPack/ExternalPackAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ struct ExternalPackAdapter: TechPack {
case let .brewInstall(package):
return .brewInstall(package: package)

case let .shellCommand(command):
return .shellCommand(command: command)
case let .shellCommand(command, interactive):
return .shellCommand(command: command, interactive: interactive)

case let .gitignoreEntries(entries):
return .gitignoreEntries(entries: entries)
Expand Down
15 changes: 11 additions & 4 deletions Sources/mcs/ExternalPack/ExternalPackManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ struct ExternalComponentDefinition: Codable {
case mcp // Map — MCPShorthand (name inferred from id)
case plugin // String — plugin full name
case shell // String — shell command (requires explicit `type`)
case shellInteractive // Bool — allocate PTY for commands needing terminal access (e.g. sudo)
case hook // Map — CopyFileShorthand (fileType: .hook)
case command // Map — CopyFileShorthand (fileType: .command)
case skill // Map — CopyFileShorthand (fileType: .skill)
Expand Down Expand Up @@ -502,7 +503,8 @@ struct ExternalComponentDefinition: Codable {
}
if shorthand.contains(.shell) {
let command = try shorthand.decode(String.self, forKey: .shell)
return ResolvedShorthand(type: nil, action: .shellCommand(command: command))
let interactive = try shorthand.decodeIfPresent(Bool.self, forKey: .shellInteractive) ?? false
return ResolvedShorthand(type: nil, action: .shellCommand(command: command, interactive: interactive))
}
if shorthand.contains(.hook) {
let config = try shorthand.decode(CopyFileShorthand.self, forKey: .hook)
Expand Down Expand Up @@ -616,7 +618,7 @@ enum ExternalInstallAction: Codable {
case mcpServer(ExternalMCPServerConfig)
case plugin(name: String)
case brewInstall(package: String)
case shellCommand(command: String)
case shellCommand(command: String, interactive: Bool = false)
case gitignoreEntries(entries: [String])
case settingsMerge
case settingsFile(source: String)
Expand All @@ -627,6 +629,7 @@ enum ExternalInstallAction: Codable {
case name
case package
case command
case interactive
case args
case env
case transport
Expand Down Expand Up @@ -654,7 +657,8 @@ enum ExternalInstallAction: Codable {
self = .brewInstall(package: package)
case .shellCommand:
let command = try container.decode(String.self, forKey: .command)
self = .shellCommand(command: command)
let interactive = try container.decodeIfPresent(Bool.self, forKey: .interactive) ?? false
self = .shellCommand(command: command, interactive: interactive)
case .gitignoreEntries:
let entries = try container.decode([String].self, forKey: .entries)
self = .gitignoreEntries(entries: entries)
Expand Down Expand Up @@ -682,9 +686,12 @@ enum ExternalInstallAction: Codable {
case let .brewInstall(package):
try container.encode(ExternalInstallActionType.brewInstall, forKey: .type)
try container.encode(package, forKey: .package)
case let .shellCommand(command):
case let .shellCommand(command, interactive):
try container.encode(ExternalInstallActionType.shellCommand, forKey: .type)
try container.encode(command, forKey: .command)
if interactive {
try container.encode(interactive, forKey: .interactive)
}
case let .gitignoreEntries(entries):
try container.encode(ExternalInstallActionType.gitignoreEntries, forKey: .type)
try container.encode(entries, forKey: .entries)
Expand Down
2 changes: 1 addition & 1 deletion Sources/mcs/ExternalPack/PackTrustManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct PackTrustManager {
if let components = manifest.components {
for component in components {
switch component.installAction {
case let .shellCommand(command):
case let .shellCommand(command, _):
items.append(TrustableItem(
type: .shellCommand,
relativePath: nil,
Expand Down
12 changes: 9 additions & 3 deletions Sources/mcs/Sync/GlobalSyncStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,17 @@ struct GlobalSyncStrategy: SyncStrategy {
artifacts.gitignoreEntries.append(contentsOf: entries)
}

case let .shellCommand(command):
output.dimmed(" Running \(component.displayName)...")
let result = shell.shell(command)
case let .shellCommand(command, interactive):
if interactive {
output.plain(" Running \(component.displayName) (may prompt for your password)...")
} else {
output.dimmed(" Running \(component.displayName)...")
}
let result = shell.shell(command, interactive: interactive)
if result.succeeded {
output.success(" \(component.displayName) installed")
} else if interactive {
output.warn(" \(component.displayName) failed (see output above)")
} else {
output.warn(" \(component.displayName) requires manual installation:")
output.plain(" \(command)")
Expand Down
Loading
Loading