Skip to content

Commit 83633e4

Browse files
committed
Add CLI args, one-shot mode & backend init
Introduce command-line argument parsing and one-shot mode for non-interactive use. Adds a CLIArguments struct to parse flags (--fm, --mlx, --mlx-model, --prompt, --temperature, --stream, --system, --tools, --help) and a printUsage() helper. CLIApp.run was extended to accept args; applyArgs configures backends from flags, initializeActiveBackend centralizes backend initialization (FM/MLX), and runOneShot handles sending a single prompt (supports streaming for FM). Also initialize MLX when selected and add FoundationModelsBackend.setStreaming(_:) to allow enabling streaming externally. These changes enable scripted usage and more flexible startup configuration while preserving the interactive REPL.
1 parent e99bd62 commit 83633e4

2 files changed

Lines changed: 187 additions & 2 deletions

File tree

Sources/PerspectiveCLI/Backends/FoundationModelsBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ actor FoundationModelsBackend {
110110
// MARK: - Configuration
111111

112112
func isStreaming() -> Bool { streamingEnabled }
113+
func setStreaming(_ enabled: Bool) { streamingEnabled = enabled }
113114
func toggleStreaming() -> Bool {
114115
streamingEnabled.toggle()
115116
return streamingEnabled

Sources/PerspectiveCLI/PerspectiveCLI.swift

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ struct PerspectiveCLI {
2121
// colocated metallib search succeeds regardless of install location.
2222
Self.chdirToExecutableDirectory()
2323

24-
await CLIApp.shared.run()
24+
let args = CLIArguments()
25+
26+
if args.help {
27+
printUsage()
28+
return
29+
}
30+
31+
await CLIApp.shared.run(with: args)
2532
}
2633

2734
/// Change working directory to the real (symlink-resolved) executable directory.
@@ -49,6 +56,50 @@ enum CLIError: LocalizedError {
4956
}
5057
}
5158

59+
// MARK: - CLI Arguments
60+
61+
struct CLIArguments {
62+
var backend: Backend?
63+
var mlxModel: String?
64+
var prompt: String?
65+
var temperature: Float?
66+
var stream: Bool = false
67+
var systemPrompt: String?
68+
var tools: Bool = false
69+
var help: Bool = false
70+
71+
init() {
72+
let args = CommandLine.arguments.dropFirst() // skip executable path
73+
var iter = args.makeIterator()
74+
while let arg = iter.next() {
75+
switch arg {
76+
case "--fm":
77+
backend = .fm
78+
case "--mlx":
79+
backend = .mlx
80+
case "--mlx-model":
81+
mlxModel = iter.next()
82+
case "--prompt":
83+
prompt = iter.next()
84+
case "--temperature":
85+
if let val = iter.next() { temperature = Float(val) }
86+
case "--stream":
87+
stream = true
88+
case "--system":
89+
systemPrompt = iter.next()
90+
case "--tools":
91+
tools = true
92+
case "--help", "-h":
93+
help = true
94+
default:
95+
printError("Unknown argument: \(arg)")
96+
help = true
97+
return
98+
}
99+
}
100+
}
101+
}
102+
52103
// MARK: - Backend Selection
53104

54105
enum Backend: String {
@@ -71,6 +122,106 @@ actor CLIApp {
71122

72123
private init() {}
73124

125+
/// Apply CLI arguments to configure backends before starting.
126+
private func applyArgs(_ args: CLIArguments) async {
127+
if let backend = args.backend {
128+
activeBackend = backend
129+
}
130+
if let system = args.systemPrompt {
131+
customSystemPrompt = system
132+
}
133+
if args.tools {
134+
toolsEnabled = true
135+
}
136+
if args.stream {
137+
await fmBackend.setStreaming(true)
138+
}
139+
if let temp = args.temperature {
140+
switch activeBackend {
141+
case .fm:
142+
await fmBackend.setTemperature(temp)
143+
case .mlx:
144+
await mlxBackend.setTemperature(temp)
145+
}
146+
}
147+
if let model = args.mlxModel {
148+
await mlxBackend.setModelId(model)
149+
}
150+
}
151+
152+
/// Initialize the active backend, returning true on success.
153+
private func initializeActiveBackend(quiet: Bool = false) async -> Bool {
154+
switch activeBackend {
155+
case .fm:
156+
let (available, msg) = FoundationModelsBackend.checkAvailability()
157+
if !available {
158+
printError(msg)
159+
return false
160+
}
161+
if !quiet { printSuccess(msg) }
162+
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
163+
if !quiet { printSuccess("FM session initialized") }
164+
return true
165+
case .mlx:
166+
do {
167+
try await mlxBackend.initialize(customPrompt: customSystemPrompt)
168+
if !quiet { printSuccess("MLX session initialized") }
169+
return true
170+
} catch {
171+
printError("Failed to initialize MLX: \(error.localizedDescription)")
172+
return false
173+
}
174+
}
175+
}
176+
177+
/// One-shot mode: send a prompt, print the response, and exit.
178+
private func runOneShot(_ prompt: String) async {
179+
guard await initializeActiveBackend(quiet: true) else { return }
180+
181+
do {
182+
switch activeBackend {
183+
case .fm:
184+
if await fmBackend.isStreaming() {
185+
let stream = try await fmBackend.streamMessage(prompt)
186+
var lastLength = 0
187+
for try await partial in stream {
188+
let newContent = String(partial.dropFirst(lastLength))
189+
print(newContent, terminator: "")
190+
fflush(stdout)
191+
lastLength = partial.count
192+
}
193+
print("")
194+
} else {
195+
let response = try await fmBackend.sendMessage(prompt)
196+
print(response)
197+
}
198+
case .mlx:
199+
let stream = try await mlxBackend.streamMessage(prompt)
200+
var lastLength = 0
201+
for try await partial in stream {
202+
let newContent = String(partial.dropFirst(lastLength))
203+
print(newContent, terminator: "")
204+
fflush(stdout)
205+
lastLength = partial.count
206+
}
207+
print("")
208+
}
209+
} catch {
210+
printError("Error: \(error.localizedDescription)")
211+
}
212+
}
213+
214+
func run(with args: CLIArguments) async {
215+
await applyArgs(args)
216+
217+
if let prompt = args.prompt {
218+
await runOneShot(prompt)
219+
return
220+
}
221+
222+
await run()
223+
}
224+
74225
func run() async {
75226
printWelcome()
76227

@@ -85,11 +236,21 @@ actor CLIApp {
85236
}
86237

87238
// Initialize FM backend if available
88-
if fmAvailable {
239+
if fmAvailable && activeBackend == .fm {
89240
await fmBackend.initialize(customPrompt: customSystemPrompt, enableTools: toolsEnabled)
90241
printSuccess("FM session initialized")
91242
}
92243

244+
// Initialize MLX backend if selected via args
245+
if activeBackend == .mlx {
246+
do {
247+
try await mlxBackend.initialize(customPrompt: customSystemPrompt)
248+
printSuccess("MLX session initialized")
249+
} catch {
250+
printError("Failed to initialize MLX: \(error.localizedDescription)")
251+
}
252+
}
253+
93254
printHelp()
94255
print("")
95256

@@ -414,3 +575,26 @@ func printWarning(_ message: String) {
414575
func printInfo(_ message: String) {
415576
print("\u{001B}[90m\(message)\u{001B}[0m")
416577
}
578+
579+
func printUsage() {
580+
print("Usage: perspective [options]")
581+
print("")
582+
print("Options:")
583+
print(" --fm Use Foundation Models backend")
584+
print(" --mlx Use MLX backend")
585+
print(" --mlx-model <id> Set MLX model (e.g. mlx-community/gemma-3-4b-it-4bit)")
586+
print(" --prompt <text> Send a prompt and exit (one-shot mode)")
587+
print(" --temperature <float> Set temperature (FM: 0.0-1.0, MLX: 0.0-2.0)")
588+
print(" --stream Enable streaming output (FM)")
589+
print(" --system <text> Set a custom system prompt")
590+
print(" --tools Enable tool calling (FM)")
591+
print(" --help, -h Show this help")
592+
print("")
593+
print("Examples:")
594+
print(" perspective --fm --prompt \"What is Swift?\"")
595+
print(" perspective --mlx --prompt \"Hello\"")
596+
print(" perspective --mlx --mlx-model mlx-community/gemma-3-4b-it-4bit")
597+
print(" perspective --temperature 0.5 --prompt \"Be creative\"")
598+
print("")
599+
print("Without --prompt, enters interactive REPL mode.")
600+
}

0 commit comments

Comments
 (0)