Skip to content

Commit 377dc5b

Browse files
authored
feat(skill): tool invocation via npx (googleapis#2916)
This PR enhances the `skills-generate` command to allow it generate skills that relies on npx for tool invocation, without the need of having a toolbox binary. More specifically, a new --invocation-mode flag (defaulting to npx, with support for binary) and a --toolbox-version flag to pin the @toolbox-sdk/server package version (defaulting to the current numerical version in version.txt).
1 parent 254c818 commit 377dc5b

File tree

7 files changed

+78
-38
lines changed

7 files changed

+78
-38
lines changed

cmd/internal/options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type ToolboxOptions struct {
4444
Configs []string
4545
ConfigFolder string
4646
PrebuiltConfigs []string
47+
VersionNum string
4748
}
4849

4950
// Option defines a function that modifies the ToolboxOptions struct.

cmd/internal/skills/command.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ type skillsCmd struct {
4040
outputDir string
4141
licenseHeader string
4242
additionalNotes string
43+
invocationMode string
44+
toolboxVersion string
4345
}
4446

4547
// NewCommand creates a new Command.
@@ -62,6 +64,8 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command {
6264
flags.StringVar(&cmd.outputDir, "output-dir", "skills", "Directory to output generated skills")
6365
flags.StringVar(&cmd.licenseHeader, "license-header", "", "Optional license header to prepend to generated node scripts.")
6466
flags.StringVar(&cmd.additionalNotes, "additional-notes", "", "Additional notes to add under the Usage section of the generated SKILL.md")
67+
flags.StringVar(&cmd.invocationMode, "invocation-mode", "npx", "Invocation mode for the generated scripts: 'binary' or 'npx'")
68+
flags.StringVar(&cmd.toolboxVersion, "toolbox-version", opts.VersionNum, "Version of @toolbox-sdk/server to use for npx approach")
6569
_ = cmd.MarkFlagRequired("name")
6670
_ = cmd.MarkFlagRequired("description")
6771
return cmd.Command
@@ -187,7 +191,7 @@ func run(cmd *skillsCmd, opts *internal.ToolboxOptions) error {
187191

188192
for _, toolName := range toolNames {
189193
// Generate wrapper script in scripts directory
190-
scriptContent, err := generateScriptContent(toolName, configArgsStr, cmd.licenseHeader)
194+
scriptContent, err := generateScriptContent(toolName, configArgsStr, cmd.licenseHeader, cmd.invocationMode, cmd.toolboxVersion)
191195
if err != nil {
192196
errMsg := fmt.Errorf("error generating script content for %s: %w", toolName, err)
193197
opts.Logger.ErrorContext(ctx, errMsg.Error())

cmd/internal/skills/generator.go

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -124,38 +124,11 @@ const nodeScriptTemplate = `#!/usr/bin/env node
124124
const { spawn, execSync } = require('child_process');
125125
const path = require('path');
126126
const fs = require('fs');
127+
const os = require('os');
127128
128129
const toolName = "{{.Name}}";
129130
const configArgs = [{{.ConfigArgs}}];
130131
131-
function getToolboxPath() {
132-
if (process.env.GEMINI_CLI === '1') {
133-
const ext = process.platform === 'win32' ? '.exe' : '';
134-
const localPath = path.resolve(__dirname, '../../../toolbox' + ext);
135-
if (fs.existsSync(localPath)) {
136-
return localPath;
137-
}
138-
}
139-
try {
140-
const checkCommand = process.platform === 'win32' ? 'where toolbox' : 'which toolbox';
141-
const globalPath = execSync(checkCommand, { stdio: 'pipe', encoding: 'utf-8' }).trim();
142-
if (globalPath) {
143-
return globalPath.split('\n')[0].trim();
144-
}
145-
throw new Error("Toolbox binary not found");
146-
} catch (e) {
147-
throw new Error("Toolbox binary not found");
148-
}
149-
}
150-
151-
let toolboxBinary;
152-
try {
153-
toolboxBinary = getToolboxPath();
154-
} catch (err) {
155-
console.error("Error:", err.message);
156-
process.exit(1);
157-
}
158-
159132
function getEnv() {
160133
const envPath = path.resolve(__dirname, '../../../.env');
161134
const env = { ...process.env };
@@ -188,9 +161,47 @@ if (process.env.GEMINI_CLI === '1') {
188161
189162
const args = process.argv.slice(2);
190163
164+
{{if eq .InvocationMode "npx"}}
165+
const command = os.platform() === 'win32' ? 'npx.cmd' : 'npx';
166+
167+
const processedArgs = os.platform() === 'win32' ? args.map(arg => arg.includes('"') ? '"' + arg.replace(/"/g, '""') + '"' : arg) : args;
168+
169+
const npxArgs = ["--yes", "@toolbox-sdk/server@{{.ToolboxVersion}}", "--log-level", "error", ...configArgs, "invoke", toolName, "--user-agent-metadata", userAgent, ...processedArgs];
170+
171+
const child = spawn(command, npxArgs, { shell: os.platform() === 'win32', stdio: 'inherit', env });
172+
{{else}}
173+
function getToolboxPath() {
174+
if (process.env.GEMINI_CLI === '1') {
175+
const ext = process.platform === 'win32' ? '.exe' : '';
176+
const localPath = path.resolve(__dirname, '../../../toolbox' + ext);
177+
if (fs.existsSync(localPath)) {
178+
return localPath;
179+
}
180+
}
181+
try {
182+
const checkCommand = process.platform === 'win32' ? 'where toolbox' : 'which toolbox';
183+
const globalPath = execSync(checkCommand, { stdio: 'pipe', encoding: 'utf-8' }).trim();
184+
if (globalPath) {
185+
return globalPath.split('\n')[0].trim();
186+
}
187+
throw new Error("Toolbox binary not found");
188+
} catch (e) {
189+
throw new Error("Toolbox binary not found");
190+
}
191+
}
192+
193+
let toolboxBinary;
194+
try {
195+
toolboxBinary = getToolboxPath();
196+
} catch (err) {
197+
console.error("Error:", err.message);
198+
process.exit(1);
199+
}
200+
191201
const toolboxArgs = ["--log-level", "error", ...configArgs, "invoke", toolName, "--user-agent-metadata", userAgent, ...args];
192202
193203
const child = spawn(toolboxBinary, toolboxArgs, { stdio: 'inherit', env });
204+
{{end}}
194205
195206
child.on('close', (code) => {
196207
process.exit(code);
@@ -203,19 +214,23 @@ child.on('error', (err) => {
203214
`
204215

205216
type scriptData struct {
206-
Name string
207-
ConfigArgs string
208-
LicenseHeader string
217+
Name string
218+
ConfigArgs string
219+
LicenseHeader string
220+
InvocationMode string
221+
ToolboxVersion string
209222
}
210223

211224
// generateScriptContent creates the content for a Node.js wrapper script.
212225
// This script invokes the toolbox CLI with the appropriate configuration
213226
// (using a generated config) and arguments to execute the specific tool.
214-
func generateScriptContent(name string, configArgs string, licenseHeader string) (string, error) {
227+
func generateScriptContent(name string, configArgs string, licenseHeader string, mode string, version string) (string, error) {
215228
data := scriptData{
216-
Name: name,
217-
ConfigArgs: configArgs,
218-
LicenseHeader: licenseHeader,
229+
Name: name,
230+
ConfigArgs: configArgs,
231+
LicenseHeader: licenseHeader,
232+
InvocationMode: mode,
233+
ToolboxVersion: version,
219234
}
220235

221236
tmpl, err := template.New("script").Parse(nodeScriptTemplate)

cmd/internal/skills/generator_test.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,14 @@ func TestGenerateScriptContent(t *testing.T) {
219219
configArgs string
220220
wantContains []string
221221
licenseHeader string
222+
mode string
223+
version string
222224
}{
223225
{
224-
name: "basic script",
226+
name: "basic script (binary default)",
225227
toolName: "test-tool",
226228
configArgs: `"--prebuilt", "test"`,
229+
mode: "binary",
227230
wantContains: []string{
228231
`const toolName = "test-tool";`,
229232
`const configArgs = ["--prebuilt", "test"];`,
@@ -244,15 +247,27 @@ func TestGenerateScriptContent(t *testing.T) {
244247
toolName: "test-tool",
245248
configArgs: `"--prebuilt", "test"`,
246249
licenseHeader: "// My License",
250+
mode: "binary",
247251
wantContains: []string{
248252
"// My License",
249253
},
250254
},
255+
{
256+
name: "npx mode script",
257+
toolName: "npx-tool",
258+
configArgs: `"--prebuilt", "test"`,
259+
mode: "npx",
260+
version: "0.31.0",
261+
wantContains: []string{
262+
`const toolName = "npx-tool";`,
263+
`const npxArgs = ["--yes", "@toolbox-sdk/server@0.31.0"`,
264+
},
265+
},
251266
}
252267

253268
for _, tt := range tests {
254269
t.Run(tt.name, func(t *testing.T) {
255-
got, err := generateScriptContent(tt.toolName, tt.configArgs, tt.licenseHeader)
270+
got, err := generateScriptContent(tt.toolName, tt.configArgs, tt.licenseHeader, tt.mode, tt.version)
256271
if err != nil {
257272
t.Fatalf("generateScriptContent() error = %v", err)
258273
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command {
105105

106106
// Set server version
107107
opts.Cfg.Version = versionString
108+
opts.VersionNum = strings.TrimSpace(versionNum)
108109

109110
// set baseCmd in, out and err the same as cmd.
110111
cmd.SetIn(opts.IOStreams.In)

docs/en/documentation/configuration/skills/_index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ toolbox <tool-source> skills-generate \
3939
- `--output-dir`: (Optional) Directory to output generated skills (default: "skills").
4040
- `--license-header`: (Optional) Optional license header to prepend to generated node scripts.
4141
- `--additional-notes`: (Optional) Additional notes to add under the Usage section of the generated SKILL.md.
42+
- `--invocation-mode`: (Optional) Invocation mode for the generated scripts: 'binary' or 'npx' (default: "npx").
43+
- `--toolbox-version`: (Optional) Version of @toolbox-sdk/server to use for npx approach (defaults to current toolbox version).
4244

4345
{{< notice note >}}
4446
**Note:** The `<skill-name>` must follow the Agent Skill [naming convention](https://agentskills.io/specification): it must contain only lowercase alphanumeric characters and hyphens, cannot start or end with a hyphen, and cannot contain consecutive hyphens (e.g., `my-skill`, `data-processing`).

docs/en/reference/cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ toolbox skills-generate --name <name> --description <description> --toolset <too
7373
- `--output-dir`: (Optional) Directory to output generated skills (default: "skills").
7474
- `--license-header`: (Optional) Optional license header to prepend to generated node scripts.
7575
- `--additional-notes`: (Optional) Additional notes to add under the Usage section of the generated SKILL.md.
76+
- `--invocation-mode`: (Optional) Invocation mode for the generated scripts: 'binary' or 'npx' (default: "npx").
77+
- `--toolbox-version`: (Optional) Version of @toolbox-sdk/server to use for npx approach (defaults to current toolbox version).
7678

7779
For more detailed instructions, see [Generate Agent Skills](../documentation/configuration/skills/_index.md).
7880

0 commit comments

Comments
 (0)