Skip to content
Open
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
35 changes: 23 additions & 12 deletions apple/Sources/NLCIApple/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ func buildPrompt(_ input: BridgeInput) -> String {
struct NLCIApple {
static func main() {
Task {
// --ping mode: check availability and exit
// --ping mode: check availability and advertise capabilities so
// Go callers can gate metadata-mode requests on what this binary
// actually supports.
if CommandLine.arguments.contains("--ping") {
if let errStr = checkAvailability() {
emit(BridgeOutput(error: errStr))
exit(1)
}
emit(BridgeOutput(command: "ok", explanation: "Apple Intelligence is available"))
emit(BridgeOutput(capabilities: ["command", "metadata"]))
exit(0)
}

Expand All @@ -70,19 +72,28 @@ struct NLCIApple {
exit(1)
}

// Run inference
// Run inference. The mode field selects which @Generable schema
// the model is constrained to produce.
do {
let session = LanguageModelSession(instructions: input.system)

let response = try await session.respond(
to: buildPrompt(input),
generating: CommandResult.self
)

emit(BridgeOutput(
command: response.content.command,
explanation: response.content.explanation
))
switch input.mode {
case "metadata":
let response = try await session.respond(
to: buildPrompt(input),
generating: ToolMetadata.self
)
emit(BridgeOutput(metadata: response.content))
default: // "command"
let response = try await session.respond(
to: buildPrompt(input),
generating: CommandResult.self
)
emit(BridgeOutput(
command: response.content.command,
explanation: response.content.explanation
))
}
exit(0)
} catch {
emit(BridgeOutput(error: "inference:\(error.localizedDescription)"))
Expand Down
5 changes: 5 additions & 0 deletions apple/Sources/NLCIApple/BridgeInput.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import Foundation

/// BridgeInput is the JSON payload sent from Go to the Swift subprocess via stdin.
/// `mode` selects which @Generable schema the model should produce. Defaults
/// to "command" so older Go binaries that don't set the field continue to get
/// today's behavior.
struct BridgeInput: Codable {
let mode: String
let system: String
let schema: String
let examples: [[String]]
Expand All @@ -10,6 +14,7 @@ struct BridgeInput: Codable {
// Custom decoder so missing keys or null never crash.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
mode = try c.decodeIfPresent(String.self, forKey: .mode) ?? "command"
system = try c.decode(String.self, forKey: .system)
schema = try c.decodeIfPresent(String.self, forKey: .schema) ?? ""
examples = try c.decodeIfPresent([[String]].self, forKey: .examples) ?? []
Expand Down
32 changes: 32 additions & 0 deletions apple/Sources/NLCIApple/BridgeOutput.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,52 @@
import Foundation

/// BridgeOutput is the JSON payload written by the Swift subprocess to stdout.
///
/// In command mode `command`/`explanation` carry the result; in metadata mode
/// `metadata` carries it instead. `--ping` responses populate `capabilities`
/// so callers can detect whether this binary supports the metadata schema
/// without trying it speculatively.
struct BridgeOutput: Codable {
let mode: String?
let command: String?
let explanation: String?
let metadata: ToolMetadata?
let capabilities: [String]?
let error: String?

init(command: String, explanation: String) {
self.mode = "command"
self.command = command
self.explanation = explanation
self.metadata = nil
self.capabilities = nil
self.error = nil
}

init(metadata: ToolMetadata) {
self.mode = "metadata"
self.command = nil
self.explanation = nil
self.metadata = metadata
self.capabilities = nil
self.error = nil
}

init(capabilities: [String]) {
self.mode = nil
self.command = "ok"
self.explanation = "Apple Intelligence is available"
self.metadata = nil
self.capabilities = capabilities
self.error = nil
}

init(error: String) {
self.mode = nil
self.command = nil
self.explanation = nil
self.metadata = nil
self.capabilities = nil
self.error = error
}
}
Expand Down
46 changes: 46 additions & 0 deletions apple/Sources/NLCIApple/MetadataResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import FoundationModels

/// ToolMetadata is the structured output of `nlci init` enrichment mode.
/// It mirrors the Go EnrichmentMetadata struct in cmd/nlci; the bridge maps
/// 1:1 onto BridgeOutput.metadata. Each field carries a @Guide describing the
/// content the model should produce, anchored in the verified command surface
/// the user prompt provides.
@Generable
struct ToolMetadata: Codable {
@Guide(description: "A single-line summary of what the tool is for, capped at roughly 80 characters. Capture the purpose, not the surface. Bad: 'docker CLI'. Good: 'Manage Docker containers, images, networks, and volumes'.")
var description: String

@Guide(description: "A multi-line system prompt (4-7 lines) suitable to be used directly as the system prompt for an LLM that translates intent into commands for this tool. Mention the binary name, any tool-specific behavior derived from the help text (defaults, required flags), and safety reminders. Avoid generic boilerplate that would apply to any CLI.")
var systemPrompt: String

@Guide(description: "Safety patterns: which command substrings need confirmation before execution, and which are absolutely forbidden. Ground each pick in real verbs from the help text or verified command list.")
var safety: SafetyRules

@Guide(description: "Map of natural-language synonym words to the command paths or capability names they refer to. Aim for 5-15 entries that cover the common ways a user might phrase intent.")
var synonyms: [SynonymEntry]
}

/// SafetyRules carry destructive-pattern lists. Both arrays may be empty for
/// non-destructive tools (e.g. read-only query CLIs).
@Generable
struct SafetyRules: Codable {
@Guide(description: "Command-substring patterns that should prompt the user before running. Use full subcommand paths (e.g. 'docker rm', not just 'rm'). Look for delete/remove/destroy/drop/purge/reset/force/prune verbs in the verified commands. Empty array if the tool has no destructive operations.")
var requireConfirmation: [String]

@Guide(description: "Command-substring patterns the user should never run. Reserved for irreversible plus sweeping operations (e.g. 'docker system prune --all --volumes --force'). Usually empty.")
var forbidden: [String]
}

/// SynonymEntry pairs a natural-language word with one or more command paths
/// or capability names. We use an array of pairs rather than a Swift
/// Dictionary because @Generable's macro support for [String: [String]] is
/// not documented; arrays of structs are the well-trodden path.
@Generable
struct SynonymEntry: Codable {
@Guide(description: "A short natural-language word a user might say (e.g. 'list', 'delete', 'show', 'clean', 'fetch').")
var keyword: String

@Guide(description: "Command paths or capability names from this tool that the keyword refers to. At least one entry. Prefer paths that exist in the verified command list.")
var targets: [String]
}
68 changes: 51 additions & 17 deletions cmd/nlci/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,14 +441,12 @@ func enrichInitDiscovery(ctx context.Context, br *backend.Router, toolName strin
}

// runMetadataEnrichment is the §B call site shared by both command_tree and
// flag_driven scaffolds. Best-effort: any failure (no router available, LLM
// error, malformed YAML, timeout) returns nil so callers fall through to
// hardcoded defaults rather than blocking the scaffold write.
//
// Apple Intelligence is skipped here: nlci-apple's Swift bridge constrains
// output to a Generable CommandResult struct, so it can't return free-form
// YAML. Run init under Ollama / llama.cpp / LM Studio to populate the
// metadata fields. Hardcoded fallback applies otherwise.
// flag_driven scaffolds. It prefers a backend's native MetadataGenerator
// capability (Apple Intelligence's @Generable schemas) when available and
// falls back to the textual YAML prompt path on backends that don't
// implement it (Ollama / llama.cpp / LM Studio). Best-effort throughout:
// any failure returns nil so the caller falls through to hardcoded defaults
// rather than blocking the scaffold write.
func runMetadataEnrichment(ctx context.Context, opts scaffoldOptions, toolName, mode, helpText string, commands []definition.Command, quiet bool) *EnrichmentMetadata {
br := opts.BackendRouter
if br == nil {
Expand All @@ -460,24 +458,37 @@ func runMetadataEnrichment(ctx context.Context, opts scaffoldOptions, toolName,
return nil
}

// Resolve so we know which backend will run, then skip Apple cleanly.
if _, err := br.Resolve(ctx); err != nil {
active, err := br.Resolve(ctx)
if err != nil {
return nil
}
if br.ActiveName() == "apple" {

enrichCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

// Native structured generation: Apple advertises this via @Generable.
if mg, ok := active.(backend.MetadataGenerator); ok {
if !quiet {
fmt.Println("Metadata enrichment skipped: Apple backend constrains output to single commands. Use --backend ollama|llamacpp|lmstudio for richer metadata.")
fmt.Println("Enriching scaffold metadata via native structured generation…")
}
return nil
result, err := mg.GenerateMetadata(enrichCtx, backend.MetadataRequest{
System: initEnrichmentSystemPrompt(),
Prompt: buildInitEnrichmentPrompt(toolName, toolName, mode, helpText, commands),
})
if err == nil && result != nil {
return convertNativeMetadata(result)
}
if !quiet {
fmt.Printf("Native metadata enrichment failed, falling back to text prompt: %v\n", err)
}
// Fall through to text-prompt path below.
}

enrichCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()

// Text-prompt path: ask for a YAML block in the model's command field
// and parse it. Used by backends that don't expose structured output.
if !quiet {
fmt.Println("Enriching scaffold metadata via inference…")
}

meta, err := enrichInitMetadata(enrichCtx, br, toolName, toolName, mode, helpText, commands)
if err != nil {
if !quiet {
Expand All @@ -490,6 +501,29 @@ func runMetadataEnrichment(ctx context.Context, opts scaffoldOptions, toolName,
return meta
}

// convertNativeMetadata maps a backend.MetadataResult (returned by the
// Apple bridge's @Generable structs) into the cmd-local EnrichmentMetadata
// shape used by the scaffold writers. Applies the same placeholder-leak
// guard the YAML parser uses, so a hallucinated "<your-tool>" in the
// description trips the same fallback.
func convertNativeMetadata(r *backend.MetadataResult) *EnrichmentMetadata {
if r == nil {
return nil
}
if containsBracketedToken(r.Description) || containsBracketedToken(r.SystemPrompt) {
return nil
}
return &EnrichmentMetadata{
Description: r.Description,
SystemPrompt: r.SystemPrompt,
Safety: definition.Safety{
RequireConfirmation: r.Safety.RequireConfirmation,
Forbidden: r.Safety.Forbidden,
},
Synonyms: r.Synonyms,
}
}

// capabilitiesAsCommands adapts a flag-driven capability list into the
// definition.Command shape used by the §B prompt. Only Name and Description
// are needed — the prompt cares about the surface, not flag bindings.
Expand Down
42 changes: 42 additions & 0 deletions cmd/nlci/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"testing"

"github.com/jakejimenez/nlci/internal/backend"
"github.com/jakejimenez/nlci/internal/definition"
)

Expand Down Expand Up @@ -155,3 +156,44 @@ func TestMergeSynonyms_NilLLM(t *testing.T) {
t.Fatalf("nil llm should pass heuristic through unchanged: %+v", merged)
}
}

func TestConvertNativeMetadata_HappyPath(t *testing.T) {
r := &backend.MetadataResult{
Description: "Manage Docker things",
SystemPrompt: "You are a docker expert.",
Safety: backend.Safety{
RequireConfirmation: []string{"docker rm"},
Forbidden: []string{},
},
Synonyms: map[string][]string{"list": {"ps"}},
}
got := convertNativeMetadata(r)
if got == nil {
t.Fatalf("expected conversion, got nil")
}
if got.Description != "Manage Docker things" {
t.Fatalf("description: got %q", got.Description)
}
if len(got.Safety.RequireConfirmation) != 1 || got.Safety.RequireConfirmation[0] != "docker rm" {
t.Fatalf("safety: got %+v", got.Safety)
}
if got.Synonyms["list"][0] != "ps" {
t.Fatalf("synonyms: got %+v", got.Synonyms)
}
}

func TestConvertNativeMetadata_RejectsPlaceholderInDescription(t *testing.T) {
r := &backend.MetadataResult{
Description: "<your-tool> CLI",
SystemPrompt: "Generic.",
}
if got := convertNativeMetadata(r); got != nil {
t.Fatalf("expected nil for placeholder in description, got %+v", got)
}
}

func TestConvertNativeMetadata_NilInput(t *testing.T) {
if got := convertNativeMetadata(nil); got != nil {
t.Fatalf("expected nil for nil input, got %+v", got)
}
}
Loading