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
90 changes: 67 additions & 23 deletions cmd/internal/skills/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"

"github.com/googleapis/genai-toolbox/cmd/internal"
"github.com/googleapis/genai-toolbox/internal/server"
Expand All @@ -33,10 +34,11 @@ import (
// skillsCmd is the command for generating skills.
type skillsCmd struct {
*cobra.Command
name string
description string
toolset string
outputDir string
name string
description string
toolset string
outputDir string
licenseHeader string
}

// NewCommand creates a new Command.
Expand All @@ -54,6 +56,7 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command {
cmd.Flags().StringVar(&cmd.description, "description", "", "Description of the generated skill")
cmd.Flags().StringVar(&cmd.toolset, "toolset", "", "Name of the toolset to convert into a skill. If not provided, all tools will be included.")
cmd.Flags().StringVar(&cmd.outputDir, "output-dir", "skills", "Directory to output generated skills")
cmd.Flags().StringVar(&cmd.licenseHeader, "license-header", "", "Optional license header to prepend to generated node scripts.")

_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("description")
Expand Down Expand Up @@ -123,6 +126,40 @@ func run(cmd *skillsCmd, opts *internal.ToolboxOptions) error {
return errMsg
}

var jsConfigArgs []string
if len(opts.PrebuiltConfigs) > 0 {
for _, pc := range opts.PrebuiltConfigs {
jsConfigArgs = append(jsConfigArgs, `"--prebuilt"`, fmt.Sprintf(`"%s"`, pc))
}
}

if opts.ToolsFolder != "" {
folderName := filepath.Base(opts.ToolsFolder)
destFolder := filepath.Join(assetsPath, folderName)
if err := copyDir(opts.ToolsFolder, destFolder); err != nil {
return err
}
jsConfigArgs = append(jsConfigArgs, `"--tools-folder"`, fmt.Sprintf(`path.join(__dirname, "..", "assets", %q)`, folderName))
} else if len(opts.ToolsFiles) > 0 {
for _, f := range opts.ToolsFiles {
baseName := filepath.Base(f)
destPath := filepath.Join(assetsPath, baseName)
if err := copyFile(f, destPath); err != nil {
return err
}
jsConfigArgs = append(jsConfigArgs, `"--tools-files"`, fmt.Sprintf(`path.join(__dirname, "..", "assets", %q)`, baseName))
}
} else if opts.ToolsFile != "" {
baseName := filepath.Base(opts.ToolsFile)
destPath := filepath.Join(assetsPath, baseName)
if err := copyFile(opts.ToolsFile, destPath); err != nil {
return err
}
jsConfigArgs = append(jsConfigArgs, `"--tools-file"`, fmt.Sprintf(`path.join(__dirname, "..", "assets", %q)`, baseName))
}

configArgsStr := strings.Join(jsConfigArgs, ", ")

// Iterate over keys to ensure deterministic order
var toolNames []string
for name := range allTools {
Expand All @@ -131,26 +168,8 @@ func run(cmd *skillsCmd, opts *internal.ToolboxOptions) error {
sort.Strings(toolNames)

for _, toolName := range toolNames {
// Generate YAML config in asset directory
minimizedContent, err := generateToolConfigYAML(opts.Cfg, toolName)
if err != nil {
errMsg := fmt.Errorf("error generating filtered config for %s: %w", toolName, err)
opts.Logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}

specificToolsFileName := fmt.Sprintf("%s.yaml", toolName)
if minimizedContent != nil {
destPath := filepath.Join(assetsPath, specificToolsFileName)
if err := os.WriteFile(destPath, minimizedContent, 0644); err != nil {
errMsg := fmt.Errorf("error writing filtered config for %s: %w", toolName, err)
opts.Logger.ErrorContext(ctx, errMsg.Error())
return errMsg
}
}

// Generate wrapper script in scripts directory
scriptContent, err := generateScriptContent(toolName, specificToolsFileName)
scriptContent, err := generateScriptContent(toolName, configArgsStr, cmd.licenseHeader)
if err != nil {
errMsg := fmt.Errorf("error generating script content for %s: %w", toolName, err)
opts.Logger.ErrorContext(ctx, errMsg.Error())
Expand Down Expand Up @@ -213,3 +232,28 @@ func (c *skillsCmd) collectTools(ctx context.Context, opts *internal.ToolboxOpti

return result, nil
}

func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}

func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
destPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(destPath, 0755)
}
return copyFile(path, destPath)
})
}
2 changes: 1 addition & 1 deletion cmd/internal/skills/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ description: hello tool
}

// Check assets
assetPath := filepath.Join(skillPath, "assets", "hello-sqlite.yaml")
assetPath := filepath.Join(skillPath, "assets", "tools.yaml")
if _, err := os.Stat(assetPath); os.IsNotExist(err) {
t.Fatalf("asset file not created: %s", assetPath)
}
Expand Down
172 changes: 61 additions & 111 deletions cmd/internal/skills/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,11 @@
package skills

import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"text/template"

"github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/sources"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/util/parameters"
)
Expand All @@ -39,10 +34,10 @@ description: {{.SkillDescription}}
All scripts can be executed using Node.js. Replace ` + "`" + `<param_name>` + "`" + ` and ` + "`" + `<param_value>` + "`" + ` with actual values.

**Bash:**
` + "`" + `node scripts/<script_name>.js '{"<param_name>": "<param_value>"}'` + "`" + `
` + "`" + `node <skill_dir>/scripts/<script_name>.js '{"<param_name>": "<param_value>"}'` + "`" + `

**PowerShell:**
` + "`" + `node scripts/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'` + "`" + `
` + "`" + `node <skill_dir>/scripts/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'` + "`" + `

## Scripts

Expand Down Expand Up @@ -118,29 +113,33 @@ func generateSkillMarkdown(skillName, skillDescription string, toolsMap map[stri
}

const nodeScriptTemplate = `#!/usr/bin/env node

{{if .LicenseHeader}}
{{.LicenseHeader}}
{{end}}
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');

const toolName = "{{.Name}}";
const toolsFileName = "{{.ToolsFileName}}";
const configArgs = [{{.ConfigArgs}}];

function getToolboxPath() {
if (process.env.GEMINI_CLI === '1') {
const localPath = path.resolve(__dirname, '../../../toolbox');
if (fs.existsSync(localPath)) {
return localPath;
}
}
try {
const checkCommand = process.platform === 'win32' ? 'where toolbox' : 'which toolbox';
const globalPath = execSync(checkCommand, { stdio: 'pipe', encoding: 'utf-8' }).trim();
if (globalPath) {
return globalPath.split('\n')[0].trim();
}
throw new Error("Toolbox binary not found");
} catch (e) {
// Ignore error;
}
const localPath = path.resolve(__dirname, '../../../toolbox');
if (fs.existsSync(localPath)) {
return localPath;
throw new Error("Toolbox binary not found");
}
throw new Error("Toolbox binary not found");
}

let toolboxBinary;
Expand All @@ -151,15 +150,38 @@ try {
process.exit(1);
}

let configArgs = [];
if (toolsFileName) {
configArgs.push("--tools-file", path.join(__dirname, "..", "assets", toolsFileName));
function getEnv() {
const envPath = path.resolve(__dirname, '../../../.env');
const env = { ...process.env };
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
envContent.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const splitIdx = trimmed.indexOf('=');
if (splitIdx !== -1) {
const key = trimmed.slice(0, splitIdx).trim();
let value = trimmed.slice(splitIdx + 1).trim();
value = value.replace(/(^['"]|['"]$)/g, '');
if (env[key] === undefined) {
env[key] = value;
}
}
}
});
}
return env;
}

let env = process.env;
if (process.env.GEMINI_CLI === '1') {
env = getEnv();
}

const args = process.argv.slice(2);
const toolboxArgs = [...configArgs, "invoke", toolName, ...args];
const toolboxArgs = ["--log-level", "error", ...configArgs, "invoke", toolName, ...args];

const child = spawn(toolboxBinary, toolboxArgs, { stdio: 'inherit' });
const child = spawn(toolboxBinary, toolboxArgs, { stdio: 'inherit', env });

child.on('close', (code) => {
process.exit(code);
Expand All @@ -173,16 +195,18 @@ child.on('error', (err) => {

type scriptData struct {
Name string
ToolsFileName string
ConfigArgs string
LicenseHeader string
}

// generateScriptContent creates the content for a Node.js wrapper script.
// This script invokes the toolbox CLI with the appropriate configuration
// (using a generated tools file) and arguments to execute the specific tool.
func generateScriptContent(name string, toolsFileName string) (string, error) {
func generateScriptContent(name string, configArgs string, licenseHeader string) (string, error) {
data := scriptData{
Name: name,
ToolsFileName: toolsFileName,
ConfigArgs: configArgs,
LicenseHeader: licenseHeader,
}

tmpl, err := template.New("script").Parse(nodeScriptTemplate)
Expand All @@ -205,105 +229,31 @@ func formatParameters(params []parameters.ParameterManifest, envVars map[string]
return "", nil
}

properties := make(map[string]interface{})
var required []string
var sb strings.Builder
sb.WriteString("#### Parameters\n\n")
sb.WriteString("| Name | Type | Description | Required | Default |\n")
sb.WriteString("| :--- | :--- | :--- | :--- | :--- |\n")

for _, p := range params {
paramMap := map[string]interface{}{
"type": p.Type,
"description": p.Description,
required := "No"
if p.Required {
required = "Yes"
}
defaultValue := ""
if p.Default != nil {
defaultValue := p.Default
// Check if default value is pre-configured, if so, remove it as the the value will be
// read by the tool at runtime and the agent does not need to be aware of it.
if strVal, ok := defaultValue.(string); ok {
defaultValue = fmt.Sprintf("`%v`", p.Default)
// Check if default value matches any env var
if strVal, ok := p.Default.(string); ok {
for _, envVal := range envVars {
if envVal == strVal {
defaultValue = nil
defaultValue = ""
break
}
}
}
if defaultValue != nil {
paramMap["default"] = defaultValue
}
}
properties[p.Name] = paramMap
if p.Required {
required = append(required, p.Name)
}
}

schema := map[string]interface{}{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}

schemaJSON, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return "", fmt.Errorf("error generating parameters schema: %w", err)
}

return fmt.Sprintf("#### Parameters\n\n```json\n%s\n```", string(schemaJSON)), nil
}

// generateToolConfigYAML generates the YAML configuration for a single tool and its dependency (source).
// It extracts the relevant tool and source configurations from the server config and formats them
// into a YAML document suitable for inclusion in the skill's assets.
func generateToolConfigYAML(cfg server.ServerConfig, toolName string) ([]byte, error) {
toolCfg, ok := cfg.ToolConfigs[toolName]
if !ok {
return nil, fmt.Errorf("error finding tool config: %s", toolName)
}

var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)

// Process Tool Config
toolWrapper := struct {
Kind string `yaml:"kind"`
Config tools.ToolConfig `yaml:",inline"`
}{
Kind: "tools",
Config: toolCfg,
}

if err := encoder.Encode(toolWrapper); err != nil {
return nil, fmt.Errorf("error encoding tool config: %w", err)
}

// Process Source Config
var toolMap map[string]interface{}
b, err := yaml.Marshal(toolCfg)
if err != nil {
return nil, fmt.Errorf("error marshaling tool config: %w", err)
}
if err := yaml.Unmarshal(b, &toolMap); err != nil {
return nil, fmt.Errorf("error unmarshaling tool config map: %w", err)
}

if sourceName, ok := toolMap["source"].(string); ok && sourceName != "" {
sourceCfg, ok := cfg.SourceConfigs[sourceName]
if !ok {
return nil, fmt.Errorf("error finding source config: %s", sourceName)
}

sourceWrapper := struct {
Kind string `yaml:"kind"`
Config sources.SourceConfig `yaml:",inline"`
}{
Kind: "sources",
Config: sourceCfg,
}

if err := encoder.Encode(sourceWrapper); err != nil {
return nil, fmt.Errorf("error encoding source config: %w", err)
}
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s |\n", p.Name, p.Type, p.Description, required, defaultValue)
}

return buf.Bytes(), nil
return sb.String(), nil
}
Loading
Loading