mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
e8e99b19a8
* Remove dead code: 5 unused symbols across internal packages
- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)
* pkg/kit: use TreeManager alias in exported signatures
NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.
* Consolidate tool-kind classification into internal/extensions
coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.
* Consolidate Anthropic OAuth detection and usage-tracker refresh
The 'is the active Anthropic credential a stored OAuth token' check was
copy-pasted at 5 sites, all prefix-matching the magic string
'stored OAuth' produced in internal/auth. Now:
- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant
* pkg/kit: consolidate model-path helpers and argument tokenizer
- ExtractModelFromPath mis-parsed model IDs containing '/' (e.g.
'openrouter/meta/llama' -> 'meta'); it now delegates to
RemoveProviderFromModel and is deprecated alongside
ExtractProviderFromPath (-> GetCurrentProvider)
- parseFields delegated to prompts.ParseCommandArgs so extension argument
parsing and builtin prompt-template parsing share one quote/escape
grammar; ParseCommandArgs now also splits on tabs (superset of both
previous tokenizers)
* Unify the two {{variable}} template engines
internal/skills and pkg/kit/template_bridge each had their own grammar:
skills rejected '{{ name }}' (whitespace) but allowed digit-first names;
the bridge was the opposite. A template behaved differently depending on
whether it was loaded as a skill prompt or via the extension API.
internal/skills is now the single engine using the superset grammar
(\{\{\s*(\w+)\s*\}\}); pkg/kit ParseTemplate/RenderTemplate are thin
adapters over it. Expand is now regex-based so whitespace placeholders
expand consistently; missing variables are still left as-is.
* internal/ui: extract switchModel helper for model-switch flow
The model-selector handler (ModelSelectedMsg) and /model slash command
duplicated the full switch sequence (thinking-level fallback, setModel,
display-state update, preference persistence, ModelChange emit) and had
already drifted in ordering. Both now call a single switchModel method.
Display state is still updated directly (no prog.Send from Update).
* extbridge: extract shared BaseContext for extension wiring
cmd/extension_context.go and internal/acpserver/session.go each built a
giant extensions.Context literal, duplicating ~15 delegation closures
(GetContextStats, GetMessages, AppendEntry, options, SetModel core,
Complete, SpawnSubagent, ...) that had to be kept in sync by hand. New
data-access fields had to be wired in both places or ACP-mode extensions
silently got nil function fields.
extbridge.BaseContext now provides the headless half; both call sites
overlay only their UI-specific closures. As a side effect ACP mode gains
previously-missing APIs (state, tree navigation, skills, template
parsing, model resolution) that were nil before. The interactive TUI
keeps its exact SetModel/ReloadExtensions ordering via overrides.
* internal/tools: extract withOAuthRetry and marshalToolResult helpers
ExecuteTool repeated the OAuth-error/re-auth/retry stanza verbatim twice
(sync and task-augmented paths) and the marshal-and-wrap stanza four
times. Both are now single helpers with identical error strings, so a
fix to OAuth retry or error categorization applies everywhere at once.
* internal/ui: extract buildShareFile with defer-based cleanup
handleShareCommand repeated the close/remove/print/return cleanup chain
four times across its temp-file write error paths. File assembly now
lives in buildShareFile with a single deferred cleanup on error.
* cmd: extract flag validation, preference restore, and provider-URL routing from runNormalMode
runNormalMode opened with ~150 lines of policy logic (flag-combination
validation, persisted model/thinking-level preference restoration, and
two subtle --provider-url model-rewrite rules). These are now standalone
functions (validateModeFlags, restorePersistedPreferences,
applyProviderURLRouting) so the routing policy is independently readable
and testable. Behaviour unchanged; ordering preserved.
* fix: address review findings on SDK godoc and nil guard
- pkg/kit: remove internal package paths from exported godoc on
ParseTemplate and the ToolKind* constants (SDK doc surface must not
reference internal packages)
- internal/tools: guard marshalToolResult against a nil CallToolResult
(json.Marshal(nil) succeeds as 'null', then result.IsError panics if
a client returns nil result with nil error)
Skipped the TreeNode Children deep-copy suggestion: the slice already
comes from TreeManager.GetChildren which returns a fresh copy per call
into a throwaway intermediate, so no internal state is exposed.
414 lines
11 KiB
Go
414 lines
11 KiB
Go
package kit
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mark3labs/kit/internal/extensions"
|
|
"github.com/mark3labs/kit/internal/models"
|
|
"github.com/mark3labs/kit/internal/prompts"
|
|
"github.com/mark3labs/kit/internal/skills"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Template Parsing Bridge for Extensions (Phase 3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ParseTemplate extracts {{variables}} from template content. The template
|
|
// grammar is shared with skill prompt templates, so a template parses
|
|
// identically regardless of which API loads it.
|
|
func ParseTemplate(name, content string) extensions.PromptTemplate {
|
|
tpl := skills.NewPromptTemplate(name, content)
|
|
vars := tpl.Variables
|
|
if vars == nil {
|
|
vars = []string{}
|
|
}
|
|
return extensions.PromptTemplate{
|
|
Name: tpl.Name,
|
|
Content: tpl.Content,
|
|
Variables: vars,
|
|
}
|
|
}
|
|
|
|
// RenderTemplate substitutes variables into template content.
|
|
// Handles {{name}} and {{ name }} (any whitespace) placeholders; missing
|
|
// variables are left as-is.
|
|
func RenderTemplate(tpl extensions.PromptTemplate, vars map[string]string) string {
|
|
t := skills.PromptTemplate{Content: tpl.Content}
|
|
return t.Expand(vars)
|
|
}
|
|
|
|
// ParseArguments parses command-line style arguments.
|
|
func ParseArguments(input string, pattern extensions.ArgumentPattern) extensions.ParseResult {
|
|
result := extensions.ParseResult{
|
|
Vars: make(map[string]string),
|
|
Flags: make(map[string]string),
|
|
}
|
|
|
|
fields := parseFields(input)
|
|
if len(fields) == 0 {
|
|
return result
|
|
}
|
|
|
|
// First field is the command itself (if present); skip it.
|
|
startIdx := 0
|
|
if len(fields) > 0 && !strings.HasPrefix(fields[0], "-") {
|
|
startIdx = 1
|
|
}
|
|
|
|
// Parse flags
|
|
i := startIdx
|
|
for i < len(fields) {
|
|
field := fields[i]
|
|
|
|
// Check for flags
|
|
if strings.HasPrefix(field, "--") {
|
|
flagName := field[2:]
|
|
if varName, ok := pattern.Flags["--"+flagName]; ok {
|
|
// Flag with value
|
|
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
|
|
result.Flags["--"+flagName] = fields[i+1]
|
|
result.Vars[varName] = fields[i+1]
|
|
i += 2
|
|
continue
|
|
}
|
|
// Boolean flag
|
|
result.Flags["--"+flagName] = "true"
|
|
result.Vars[varName] = "true"
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(field, "-") && len(field) > 1 {
|
|
flagName := field[1:]
|
|
if varName, ok := pattern.Flags["-"+flagName]; ok {
|
|
// Flag with value
|
|
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
|
|
result.Flags["-"+flagName] = fields[i+1]
|
|
result.Vars[varName] = fields[i+1]
|
|
i += 2
|
|
continue
|
|
}
|
|
// Boolean flag
|
|
result.Flags["-"+flagName] = "true"
|
|
result.Vars[varName] = "true"
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
|
|
i++
|
|
}
|
|
|
|
// Collect remaining as positional args and "rest"
|
|
positional := make([]string, 0)
|
|
i = startIdx
|
|
for i < len(fields) {
|
|
field := fields[i]
|
|
if !strings.HasPrefix(field, "-") {
|
|
// Check if this was consumed as a flag value
|
|
consumed := false
|
|
for _, v := range result.Vars {
|
|
if v == field {
|
|
// Might be consumed, check previous field
|
|
if i > 0 {
|
|
prev := fields[i-1]
|
|
if strings.HasPrefix(prev, "-") {
|
|
consumed = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !consumed {
|
|
positional = append(positional, field)
|
|
}
|
|
}
|
|
i++
|
|
}
|
|
|
|
// Map positional args
|
|
for i, name := range pattern.Positional {
|
|
if i < len(positional) {
|
|
result.Vars[name] = positional[i]
|
|
}
|
|
}
|
|
|
|
// Set rest
|
|
if pattern.Rest != "" && len(positional) > len(pattern.Positional) {
|
|
restStart := len(pattern.Positional)
|
|
if restStart < len(positional) {
|
|
result.Vars[pattern.Rest] = strings.Join(positional[restStart:], " ")
|
|
}
|
|
}
|
|
|
|
result.Rest = strings.Join(fields, " ")
|
|
return result
|
|
}
|
|
|
|
// SimpleParseArguments parses $1, $2, $@ style arguments.
|
|
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
|
|
func SimpleParseArguments(input string, count int) []string {
|
|
fields := parseFields(input)
|
|
result := make([]string, 0, count+2)
|
|
result = append(result, input) // [0] = full input
|
|
|
|
// [1]..[count] = positional args
|
|
for i := range count {
|
|
if i < len(fields) {
|
|
result = append(result, fields[i])
|
|
} else {
|
|
result = append(result, "")
|
|
}
|
|
}
|
|
|
|
// [n] = $@ (all remaining)
|
|
if len(fields) > count {
|
|
result = append(result, strings.Join(fields[count:], " "))
|
|
} else {
|
|
result = append(result, "")
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// parseFields splits input into arguments respecting quoted strings and
|
|
// backslash escaping. It delegates to the canonical tokenizer in
|
|
// internal/prompts so extension argument parsing and builtin prompt-template
|
|
// parsing agree on grammar.
|
|
func parseFields(input string) []string {
|
|
return prompts.ParseCommandArgs(input)
|
|
}
|
|
|
|
// EvaluateModelConditional checks if condition matches current model.
|
|
// Condition supports wildcards: * matches any, ? matches single char.
|
|
func EvaluateModelConditional(currentModel, condition string) bool {
|
|
// Handle comma-separated conditions (OR logic)
|
|
for c := range strings.SplitSeq(condition, ",") {
|
|
c = strings.TrimSpace(c)
|
|
if matchModelPattern(currentModel, c) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// modelPatternCache caches compiled regexps for model glob patterns.
|
|
var modelPatternCache sync.Map
|
|
|
|
// matchModelPattern matches a model against a pattern with wildcards.
|
|
// Compiled regexps are cached to avoid recompilation on hot paths.
|
|
func matchModelPattern(model, pattern string) bool {
|
|
rePattern := "^" + strings.ReplaceAll(strings.ReplaceAll(pattern, "*", ".*"), "?", ".") + "$"
|
|
var re *regexp.Regexp
|
|
if v, ok := modelPatternCache.Load(rePattern); ok {
|
|
re = v.(*regexp.Regexp)
|
|
} else {
|
|
compiled, err := regexp.Compile(rePattern)
|
|
if err != nil {
|
|
// Fallback: exact match
|
|
return model == pattern
|
|
}
|
|
modelPatternCache.Store(rePattern, compiled)
|
|
re = compiled
|
|
}
|
|
return re.MatchString(model)
|
|
}
|
|
|
|
// RenderWithModelConditionals processes <if-model> blocks in content.
|
|
func RenderWithModelConditionals(content, currentModel string) string {
|
|
// Simple regex-based processor for <if-model> blocks
|
|
// Supports: <if-model is="pattern">content</if-model>
|
|
// And: <if-model is="pattern">content<else>other</if-model>
|
|
|
|
result := content
|
|
|
|
// Pattern for if-model blocks
|
|
ifModelRegex := regexp.MustCompile(`(?s)<if-model\s+is="([^"]+)">(.*?)(?:<else>(.*?))?</if-model>`)
|
|
|
|
for {
|
|
match := ifModelRegex.FindStringSubmatchIndex(result)
|
|
if match == nil {
|
|
break
|
|
}
|
|
|
|
condition := result[match[2]:match[3]]
|
|
ifContent := result[match[4]:match[5]]
|
|
elseContent := ""
|
|
if match[6] >= 0 && match[7] >= 0 {
|
|
elseContent = result[match[6]:match[7]]
|
|
}
|
|
|
|
var replacement string
|
|
if EvaluateModelConditional(currentModel, condition) {
|
|
replacement = ifContent
|
|
} else {
|
|
replacement = elseContent
|
|
}
|
|
|
|
result = result[:match[0]] + replacement + result[match[1]:]
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Model Resolution Bridge for Extensions (Phase 4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ResolveModelChain attempts each model in order until one is available.
|
|
func ResolveModelChain(preferences []string) extensions.ModelResolutionResult {
|
|
result := extensions.ModelResolutionResult{
|
|
Attempted: make([]string, 0, len(preferences)),
|
|
}
|
|
|
|
registry := models.GetGlobalRegistry()
|
|
|
|
for _, pref := range preferences {
|
|
pref = strings.TrimSpace(pref)
|
|
result.Attempted = append(result.Attempted, pref)
|
|
|
|
// Parse model string
|
|
provider, modelID, err := models.ParseModelString(pref)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check if provider exists
|
|
if registry.GetProviderInfo(provider) == nil {
|
|
continue
|
|
}
|
|
|
|
// Check if model exists in registry
|
|
modelInfo := registry.LookupModel(provider, modelID)
|
|
if modelInfo == nil {
|
|
// Try with just the model as bare name
|
|
continue
|
|
}
|
|
|
|
// Found available model
|
|
result.Model = provider + "/" + modelID
|
|
result.Capabilities = extensions.ModelCapabilities{
|
|
Provider: provider,
|
|
ModelID: modelID,
|
|
ContextLimit: modelInfo.Limit.Context,
|
|
OutputLimit: modelInfo.Limit.Output,
|
|
Reasoning: modelInfo.Reasoning,
|
|
Streaming: true, // Assume streaming support
|
|
}
|
|
return result
|
|
}
|
|
|
|
result.Error = "no models in chain are available"
|
|
return result
|
|
}
|
|
|
|
// GetModelCapabilities returns capabilities for a specific model.
|
|
// If model is empty, returns zero capabilities.
|
|
func GetModelCapabilities(model string) (extensions.ModelCapabilities, string) {
|
|
if model == "" {
|
|
return extensions.ModelCapabilities{}, "no model specified"
|
|
}
|
|
|
|
provider, modelID, err := models.ParseModelString(model)
|
|
if err != nil {
|
|
return extensions.ModelCapabilities{}, err.Error()
|
|
}
|
|
|
|
registry := models.GetGlobalRegistry()
|
|
modelInfo := registry.LookupModel(provider, modelID)
|
|
if modelInfo == nil {
|
|
return extensions.ModelCapabilities{}, "model not found in registry"
|
|
}
|
|
|
|
return extensions.ModelCapabilities{
|
|
Provider: provider,
|
|
ModelID: modelID,
|
|
ContextLimit: modelInfo.Limit.Context,
|
|
OutputLimit: modelInfo.Limit.Output,
|
|
Reasoning: modelInfo.Reasoning,
|
|
Streaming: true,
|
|
}, ""
|
|
}
|
|
|
|
// CheckModelAvailable verifies if a model string is valid and provider exists.
|
|
func CheckModelAvailable(model string) bool {
|
|
provider, _, err := models.ParseModelString(model)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
registry := models.GetGlobalRegistry()
|
|
if registry.GetProviderInfo(provider) == nil {
|
|
return false
|
|
}
|
|
|
|
// Model doesn't need to be in registry - could be dynamic/Ollama
|
|
return true
|
|
}
|
|
|
|
// GetCurrentProvider extracts provider from model string.
|
|
func GetCurrentProvider(model string) string {
|
|
provider, _, _ := models.ParseModelString(model)
|
|
return provider
|
|
}
|
|
|
|
// GetCurrentModelID extracts model ID from model string.
|
|
func GetCurrentModelID(model string) string {
|
|
_, modelID, _ := models.ParseModelString(model)
|
|
return modelID
|
|
}
|
|
|
|
// JoinModel combines provider and model ID into a model string.
|
|
func JoinModel(provider, modelID string) string {
|
|
if provider == "" {
|
|
return modelID
|
|
}
|
|
return provider + "/" + modelID
|
|
}
|
|
|
|
// MatchModelGlob matches a model against a glob pattern.
|
|
// Pattern can contain * (match any) and ? (match single).
|
|
func MatchModelGlob(model, pattern string) bool {
|
|
return matchModelPattern(model, pattern)
|
|
}
|
|
|
|
// ExtractProviderFromPath extracts provider from a path-like model string.
|
|
//
|
|
// Deprecated: Use GetCurrentProvider instead.
|
|
func ExtractProviderFromPath(model string) string {
|
|
return GetCurrentProvider(model)
|
|
}
|
|
|
|
// ExtractModelFromPath extracts model ID from a path-like model string.
|
|
//
|
|
// Deprecated: Use RemoveProviderFromModel instead, which correctly handles
|
|
// model IDs containing "/" (e.g. "openrouter/meta/llama").
|
|
func ExtractModelFromPath(model string) string {
|
|
return RemoveProviderFromModel(model)
|
|
}
|
|
|
|
// IsBareModelID checks if a string is a bare model ID (no provider).
|
|
func IsBareModelID(model string) bool {
|
|
return !strings.Contains(model, "/")
|
|
}
|
|
|
|
// AddProviderToModel adds a provider prefix to a bare model ID.
|
|
func AddProviderToModel(provider, model string) string {
|
|
if strings.Contains(model, "/") {
|
|
return model // Already has provider
|
|
}
|
|
return provider + "/" + model
|
|
}
|
|
|
|
// RemoveProviderFromModel removes the provider prefix from a model string.
|
|
func RemoveProviderFromModel(model string) string {
|
|
parts := strings.SplitN(model, "/", 2)
|
|
if len(parts) == 2 {
|
|
return parts[1]
|
|
}
|
|
return model
|
|
}
|