Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
231 lines
8.6 KiB
Go
231 lines
8.6 KiB
Go
package runtimeprep
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
const claudeSystemPromptFileEnv = "TUTTI_CLAUDE_SYSTEM_PROMPT_FILE"
|
|
const claudePluginDirEnv = "TUTTI_CLAUDE_PLUGIN_DIR"
|
|
const claudeAdditionalDirectoriesEnv = "TUTTI_CLAUDE_ADDITIONAL_DIRECTORIES_JSON"
|
|
const claudeSkillListingBudgetEnv = "SLASH_COMMAND_TOOL_CHAR_BUDGET"
|
|
const claudeSkillListingBudgetChars = "20000"
|
|
|
|
// claudeCodeExecutableEnvName always wins inside the sidecar, even over a
|
|
// bundled native SDK binary — it is the operator escape hatch.
|
|
const claudeCodeExecutableEnvName = "CLAUDE_CODE_EXECUTABLE"
|
|
|
|
// claudeCodeFallbackExecutableEnvName is consumed by the sidecar only when the
|
|
// SDK cannot resolve a native binary next to itself (the packaged bundle no
|
|
// longer vendors one). See packages/agent/claude-sdk-sidecar/src/executablePath.ts.
|
|
const claudeCodeFallbackExecutableEnvName = "TUTTI_CLAUDE_CODE_FALLBACK_EXECUTABLE"
|
|
|
|
// claudeCodeManagedPointerRelPath locates the pointer written by tuttid's
|
|
// claude binary provisioner. Contract with
|
|
// services/tuttid/service/agentstatus/claude_binary.go — keep in sync.
|
|
const claudeCodeManagedPointerRelPath = "agent-providers/claude-code/current.json"
|
|
|
|
type ClaudeCodePreparer struct {
|
|
// StateDir is the tutti state root that hosts the managed claude binary
|
|
// pointer; empty disables the managed-binary fallback.
|
|
StateDir string
|
|
// PersonalSkillRoot is an optional stable user Skill root outside Claude's
|
|
// native config home. It is projected as an SDK additional directory so
|
|
// Claude loads the Skills with their ordinary, un-namespaced slash commands.
|
|
PersonalSkillRoot string
|
|
}
|
|
|
|
func (ClaudeCodePreparer) Provider() string {
|
|
return "claude-code"
|
|
}
|
|
|
|
func (p ClaudeCodePreparer) Prepare(_ context.Context, input ProviderPrepareInput) (ProviderPrepareResult, error) {
|
|
systemPromptPath := filepath.Join(input.RuntimeRoot, "claude-system-prompt.md")
|
|
if err := os.MkdirAll(filepath.Dir(systemPromptPath), 0o700); err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("create claude system prompt directory: %w", err)
|
|
}
|
|
policy, err := tuttiCLIPolicy(input.PrepareInput)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
systemPrompt := joinPromptSections(
|
|
policy,
|
|
agentConversationDetailModeSystemPromptAppend(input.ConversationDetailMode),
|
|
)
|
|
if err := os.WriteFile(systemPromptPath, []byte(systemPrompt), 0o600); err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("write claude system prompt: %w", err)
|
|
}
|
|
if input.Manifest != nil {
|
|
input.Manifest.RecordManagedFile(systemPromptPath, "provider-system-prompt", true)
|
|
}
|
|
env := []string{
|
|
claudeSystemPromptFileEnv + "=" + systemPromptPath,
|
|
}
|
|
if !input.SkipSkills {
|
|
pluginDir := filepath.Join(input.RuntimeRoot, "claude-plugin", "tutti-cli")
|
|
if err := installClaudeTuttiPlugin(pluginDir, input.PrepareInput); err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
if input.Manifest != nil {
|
|
input.Manifest.RecordManagedFile(pluginDir, "provider-plugin", true)
|
|
}
|
|
env = append(env,
|
|
claudePluginDirEnv+"="+pluginDir,
|
|
claudeSkillListingBudgetEnv+"="+claudeSkillListingBudgetChars,
|
|
)
|
|
additionalDirectory, err := prepareClaudePersonalSkillDirectory(input.RuntimeRoot, p.PersonalSkillRoot)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
if additionalDirectory != "" {
|
|
encodedDirectories, err := json.Marshal([]string{additionalDirectory})
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("encode Claude additional directories: %w", err)
|
|
}
|
|
env = append(env, claudeAdditionalDirectoriesEnv+"="+string(encodedDirectories))
|
|
if input.Manifest != nil {
|
|
input.Manifest.RecordManagedFile(additionalDirectory, "provider-personal-skills", true)
|
|
}
|
|
}
|
|
}
|
|
env = append(env, p.claudeCodeExecutableEnv()...)
|
|
env = append(env, modelEndpointClaudeEnv(input.ModelEndpoint)...)
|
|
// Plan mode is enabled through the SDK permission mode selected by the
|
|
// daemon runtime. We intentionally do NOT set
|
|
// CLAUDE_CONFIG_DIR to seed `permissions.defaultMode=plan`: pointing the CLI
|
|
// at a fresh config directory makes it stop reading the user's real
|
|
// credentials (keychain on macOS, ~/.claude/.credentials.json elsewhere),
|
|
// so plan turns fail with "Not logged in · Please run /login" (-32000) for
|
|
// OAuth users while every other mode keeps working. input.PlanMode is left
|
|
// unused here on purpose.
|
|
return ProviderPrepareResult{
|
|
Cwd: input.Cwd,
|
|
Env: env,
|
|
}, nil
|
|
}
|
|
|
|
func installClaudeTuttiPlugin(pluginDir string, input PrepareInput) error {
|
|
manifestDir := filepath.Join(pluginDir, ".claude-plugin")
|
|
if err := os.MkdirAll(manifestDir, 0o700); err != nil {
|
|
return fmt.Errorf("create claude plugin manifest directory: %w", err)
|
|
}
|
|
manifest := map[string]any{
|
|
"name": "tutti-cli",
|
|
"version": "0.1.0",
|
|
"description": "Tutti CLI skill for AgentGUI sessions.",
|
|
"author": map[string]string{
|
|
"name": "Tutti",
|
|
},
|
|
}
|
|
content, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode claude plugin manifest: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(manifestDir, "plugin.json"), append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write claude plugin manifest: %w", err)
|
|
}
|
|
if _, err := installProviderNativeSkillsSessionScoped(filepath.Join(pluginDir, "skills"), input); err != nil {
|
|
return fmt.Errorf("install claude tutti skill plugin: %w", err)
|
|
}
|
|
if input.RTKSaverMode {
|
|
if err := installClaudeRTKHook(pluginDir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func installClaudeRTKHook(pluginDir string) error {
|
|
hooksDir := filepath.Join(pluginDir, "hooks")
|
|
if err := os.MkdirAll(hooksDir, 0o700); err != nil {
|
|
return fmt.Errorf("create claude RTK hooks directory: %w", err)
|
|
}
|
|
document := map[string]any{
|
|
"hooks": map[string]any{
|
|
"PreToolUse": []any{map[string]any{
|
|
"matcher": "Bash",
|
|
"hooks": []any{map[string]any{
|
|
"type": "command",
|
|
"command": "rtk hook claude",
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
content, err := json.MarshalIndent(document, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode claude RTK hooks: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(hooksDir, "hooks.json"), append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write claude RTK hooks: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// claudeCodeExecutableEnv selects which claude binary the sidecar should
|
|
// spawn. An explicit CLAUDE_CODE_EXECUTABLE override is forwarded as-is and
|
|
// beats everything. Otherwise a fallback executable is offered — preferred:
|
|
// the tuttid-provisioned binary pinned to the vendored SDK version; last
|
|
// resort: a PATH-installed claude (version unpinned, but session launch
|
|
// already requires one, so it is always present on working setups). The
|
|
// sidecar only uses the fallback when the SDK cannot self-resolve a native
|
|
// binary next to itself.
|
|
func (p ClaudeCodePreparer) claudeCodeExecutableEnv() []string {
|
|
if configured := strings.TrimSpace(os.Getenv(claudeCodeExecutableEnvName)); configured != "" {
|
|
return []string{claudeCodeExecutableEnvName + "=" + configured}
|
|
}
|
|
if managed := p.managedClaudeCodeExecutable(); managed != "" {
|
|
return []string{claudeCodeFallbackExecutableEnvName + "=" + managed}
|
|
}
|
|
claudePath, err := exec.LookPath("claude")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return []string{claudeCodeFallbackExecutableEnvName + "=" + claudePath}
|
|
}
|
|
|
|
func (p ClaudeCodePreparer) managedClaudeCodeExecutable() string {
|
|
stateDir := strings.TrimSpace(p.StateDir)
|
|
if stateDir == "" {
|
|
return ""
|
|
}
|
|
pointerPath := filepath.Join(stateDir, filepath.FromSlash(claudeCodeManagedPointerRelPath))
|
|
content, err := os.ReadFile(pointerPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
// The pointer version is intentionally NOT validated against the app's
|
|
// vendored SDK here. Right after an app update, the pointer briefly
|
|
// references the previous release's binary until tuttid's background
|
|
// provisioning repoints it — and a binary one release behind is a strictly
|
|
// better fallback than the PATH claude of arbitrary age this would
|
|
// otherwise degrade to. The SDK↔CLI protocol tolerates that skew by
|
|
// design (pathToClaudeCodeExecutable exists for externally-installed,
|
|
// unpinned CLIs).
|
|
var pointer struct {
|
|
Executable string `json:"executable"`
|
|
}
|
|
if err := json.Unmarshal(content, &pointer); err != nil {
|
|
return ""
|
|
}
|
|
executable := strings.TrimSpace(pointer.Executable)
|
|
if executable == "" {
|
|
return ""
|
|
}
|
|
info, err := os.Stat(executable)
|
|
if err != nil || info.IsDir() {
|
|
return ""
|
|
}
|
|
// Windows file modes never expose Unix execute bits; only gate on them
|
|
// where they exist.
|
|
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
|
|
return ""
|
|
}
|
|
return executable
|
|
}
|