Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
488 lines
19 KiB
Go
488 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Tencent/WeKnora/internal/sandbox"
|
|
)
|
|
|
|
// skillPythonVerifier is the source of the in-sandbox Python checker. It is
|
|
// shipped as a file rather than a Go string literal so it can be linted and
|
|
// read as Python; the install path base64-encodes it into one shell command.
|
|
//
|
|
//go:embed tenant_skill_verify.py
|
|
var skillPythonVerifier string
|
|
|
|
// skillVerifyRepairableExit is the exit code a verification pass uses when
|
|
// every problem it found is a dependency missing from this image. It separates
|
|
// "another installer round can fix this" from "the bundle has to change", which
|
|
// is the only distinction that decides what the install flow does next.
|
|
const skillVerifyRepairableExit = 2
|
|
|
|
// skillTreeVerifyDirExit is the exit code the tree check uses when the skill
|
|
// directory itself is gone, so no file inside it can be probed. Exit 1 is the
|
|
// shared "every finding is one stderr line" code; exit 0 means everything the
|
|
// bundle names is still in place.
|
|
const skillTreeVerifyDirExit = 3
|
|
|
|
// skillVerifyNotePrefix marks a line the checker reports without refusing the
|
|
// install. Notes travel on stdout so a non-zero exit stays unambiguous.
|
|
const skillVerifyNotePrefix = "note: "
|
|
|
|
// skillVerificationError is what the gate said, kept structured because it is
|
|
// also the brief for a repair round. The gate is the only authority on what has
|
|
// to resolve in this image, so handing back its own lines is what keeps the
|
|
// installer from deriving "what this skill needs" a second time.
|
|
type skillVerificationError struct {
|
|
// Language names the pass that failed, as the operator sees it.
|
|
Language string
|
|
// Repairable is true when installing a package would satisfy every line.
|
|
Repairable bool
|
|
// Problems are the checker's own lines, one per finding.
|
|
Problems []string
|
|
// Summary describes the command result itself, and is the only thing worth
|
|
// printing when the pass died without producing lines at all.
|
|
Summary string
|
|
}
|
|
|
|
func (e *skillVerificationError) Error() string {
|
|
if len(e.Problems) == 0 {
|
|
return fmt.Sprintf("%s verification failed (%s)", e.Language, e.Summary)
|
|
}
|
|
return fmt.Sprintf("%s verification failed: %s", e.Language, strings.Join(e.Problems, "; "))
|
|
}
|
|
|
|
// verifySkill is the server's own check, and the last gate before the image
|
|
// pointer moves: a broken install must leave the previous snapshot serving.
|
|
//
|
|
// It checks only what a file can settle, never what a runtime decides. Every
|
|
// pass here is deterministic: the files the bundle named are present, the
|
|
// isolated dependency trees the installer was told to create exist, every
|
|
// source parses with the interpreter that would run it, and every distribution
|
|
// the manifests name is installed.
|
|
//
|
|
// Import resolution is deliberately absent. Whether `import helper` resolves
|
|
// depends on what a script does to sys.path before the import runs, which no
|
|
// static evaluator can enumerate — and every approximation of it refused
|
|
// skills that run perfectly. That proof belongs to the installer agent, which
|
|
// holds a root shell and the real interpreter and can simply run the import.
|
|
// See the header of tenant_skill_verify.py.
|
|
//
|
|
// Findings are graded rather than uniformly fatal. Refusing an install costs
|
|
// the minutes of dependency work that already succeeded, so only evidence that
|
|
// the install itself is broken may do it: a finding in a file nothing the
|
|
// skill offers ever loads, or a requirement pip would have skipped, is
|
|
// returned as a note.
|
|
func (s *TenantSkillService) verifySkill(
|
|
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string, bundle *SkillBundle,
|
|
) ([]string, error) {
|
|
if err := s.verifySkillTree(ctx, mgr, sessionID, skillDir, bundle); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.verifyDeclaredDependencies(ctx, mgr, sessionID, skillDir, bundle); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.verifyScriptsParse(ctx, mgr, sessionID, skillDir, bundle)
|
|
}
|
|
|
|
// verifySkillTree confirms the files the agent was given are still the files
|
|
// the image carries. The agent has a root shell in this directory, so "we
|
|
// wrote it before the agent ran" is not evidence that it survived.
|
|
//
|
|
// The whole check is one command and one round trip, however many files the
|
|
// bundle carries: a missing file costs one remote exec per call otherwise, and
|
|
// a skill with dozens of scripts was spending minutes on that round-trip tax.
|
|
// Every missing file is reported on its own stderr line and the check keeps
|
|
// going, so one round trip returns every finding instead of stopping at the
|
|
// first — an install that fails anyway may as well fail completely.
|
|
func (s *TenantSkillService) verifySkillTree(
|
|
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string, bundle *SkillBundle,
|
|
) error {
|
|
res, err := s.execInstall(ctx, mgr, sessionID,
|
|
skillTreeVerifyCommand(skillDir, sortedScriptPaths(bundle, allScriptExtensions...)))
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if res != nil {
|
|
switch res.ExitCode {
|
|
case skillTreeVerifyDirExit:
|
|
// The directory itself is gone; nothing inside it could be probed
|
|
// and the command already said exactly that.
|
|
return errors.New("skill directory is incomplete after install")
|
|
case 1:
|
|
// The command's own protocol: exit 1 means every finding is one
|
|
// stderr line, and those lines are the final message. Any other
|
|
// non-zero exit has no protocol behind it and falls through to
|
|
// the transport wrapping below rather than promoting arbitrary
|
|
// stderr noise to a verdict.
|
|
if lines := verificationProblems(res.Stderr); len(lines) > 0 {
|
|
return errors.New(strings.Join(lines, "; "))
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("skill tree verification failed: %w", err)
|
|
}
|
|
|
|
// skillTreeVerifyCommand folds the structural check into one command: the
|
|
// SKILL.md probe plus one test per bundled script, each missing file reported
|
|
// without stopping the rest. It cds into the skill directory and iterates
|
|
// relative paths, which makes every stderr line the final user-facing message
|
|
// — the Go side never has to reassemble one. Paths reach the command only
|
|
// through ShellQuote: a file name comes from an uploaded archive, and a
|
|
// metacharacter in one must stay a literal.
|
|
func skillTreeVerifyCommand(skillDir string, scripts []string) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "cd %s || { echo 'skill directory is incomplete after install' >&2; exit %d; }",
|
|
sandbox.ShellQuote(skillDir), skillTreeVerifyDirExit)
|
|
b.WriteString("; status=0")
|
|
b.WriteString("; [ -f 'SKILL.md' ] || { echo 'SKILL.md is missing after install' >&2; status=1; }")
|
|
if len(scripts) > 0 {
|
|
b.WriteString("; for f in")
|
|
for _, rel := range scripts {
|
|
b.WriteByte(' ')
|
|
b.WriteString(sandbox.ShellQuote(rel))
|
|
}
|
|
b.WriteString(`; do [ -f "$f" ] || { echo "script $f is missing after install" >&2; status=1; }; done`)
|
|
}
|
|
b.WriteString("; exit $status")
|
|
return b.String()
|
|
}
|
|
|
|
// verifyDeclaredDependencies checks that the isolated trees the installer was
|
|
// told to create actually exist. Seeded source files surviving is not evidence
|
|
// that pip/npm ran: those files were written server-side before the agent.
|
|
//
|
|
// This is only the shape of the install. Whether the individual packages
|
|
// landed is checked by the language passes below, which read the manifests.
|
|
func (s *TenantSkillService) verifyDeclaredDependencies(
|
|
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string, bundle *SkillBundle,
|
|
) error {
|
|
if bundleHasPythonDeps(bundle) {
|
|
venvPython := path.Join(skillDir, ".venv", "bin", "python")
|
|
if _, err := s.execInstall(ctx, mgr, sessionID,
|
|
fmt.Sprintf("test -x %s", sandbox.ShellQuote(venvPython))); err != nil {
|
|
return fmt.Errorf("python dependencies were not installed into %s/.venv: %w", skillDir, err)
|
|
}
|
|
}
|
|
if bundleHasNodeDeps(bundle) {
|
|
nodeModules := path.Join(skillDir, "node_modules")
|
|
if _, err := s.execInstall(ctx, mgr, sessionID,
|
|
fmt.Sprintf("test -d %s", sandbox.ShellQuote(nodeModules))); err != nil {
|
|
return fmt.Errorf("node dependencies were not installed into %s/node_modules: %w", skillDir, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// verifyScriptsParse runs one pass per language present in the bundle. Each
|
|
// pass covers every file of that language: parsing is a property of the file,
|
|
// not of a guessed entry point.
|
|
//
|
|
// All three passes are parse-only — `ast.parse`, `node --check`, `bash -n`.
|
|
// None of them executes the skill's code and none of them decides whether an
|
|
// import resolves. The Python pass additionally checks the manifests against
|
|
// what pip actually landed, which is the one finding here another installer
|
|
// round can still fix.
|
|
//
|
|
// Only the Python pass takes the auxiliary split, because it is the only one
|
|
// whose findings depend on what the image carries: a bundled tests/ directory
|
|
// naming a package the venv does not have must not fail an install that works.
|
|
func (s *TenantSkillService) verifyScriptsParse(
|
|
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string, bundle *SkillBundle,
|
|
) ([]string, error) {
|
|
var notes []string
|
|
if scripts := sortedScriptPaths(bundle, ".py"); len(scripts) > 0 {
|
|
entry, auxiliary := splitAuxiliaryScripts(scripts)
|
|
found, err := s.execVerify(ctx, mgr, sessionID, skillDir, "python",
|
|
skillPythonVerifyCommand(skillDir, entry, auxiliary))
|
|
notes = append(notes, found...)
|
|
if err != nil {
|
|
return notes, err
|
|
}
|
|
}
|
|
if scripts := sortedScriptPaths(bundle, ".js", ".mjs", ".cjs"); len(scripts) > 0 {
|
|
found, err := s.execVerify(ctx, mgr, sessionID, skillDir, "node",
|
|
skillNodeVerifyCommand(skillDir, scripts, nodeDependencyNames(bundle)))
|
|
notes = append(notes, found...)
|
|
if err != nil {
|
|
return notes, err
|
|
}
|
|
}
|
|
if scripts := sortedScriptPaths(bundle, ".sh"); len(scripts) > 0 {
|
|
found, err := s.execVerify(ctx, mgr, sessionID, skillDir, "shell",
|
|
skillShellVerifyCommand(skillDir, scripts))
|
|
notes = append(notes, found...)
|
|
if err != nil {
|
|
return notes, err
|
|
}
|
|
}
|
|
return notes, nil
|
|
}
|
|
|
|
// execVerify runs one verification pass as the ordinary sandbox user, with the
|
|
// same working directory and environment a real skill call gets. Running it as
|
|
// install-mode root would test permissions that never reach a session.
|
|
func (s *TenantSkillService) execVerify(
|
|
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir, label, command string,
|
|
) ([]string, error) {
|
|
executor, err := installExecutor(mgr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := executor.ExecShellCommandWithOptions(ctx, sessionID, command,
|
|
sandbox.ShellExecOptions{
|
|
WorkDir: sandbox.SessionWorkspaceRoot,
|
|
Timeout: installCommandTimeout,
|
|
Env: map[string]string{
|
|
"WEKNORA_SKILL_DIR": skillDir,
|
|
"WEKNORA_SKILL_OUTPUT_DIR": sandbox.SessionOutputRoot,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s verification: %w", label, err)
|
|
}
|
|
// Notes are read even from a failed pass: an install refused for a missing
|
|
// package may also have noticed something about a file it did not refuse
|
|
// over, and that is exactly the context whoever reads the failure needs.
|
|
notes := verificationNotes(res.Stdout)
|
|
if res.ExitCode != 0 {
|
|
return notes, &skillVerificationError{
|
|
Language: label,
|
|
Repairable: res.ExitCode == skillVerifyRepairableExit,
|
|
Problems: verificationProblems(res.Stderr),
|
|
Summary: describeExecFailure(res),
|
|
}
|
|
}
|
|
return notes, nil
|
|
}
|
|
|
|
// verificationNotes pulls the reported-but-not-refused findings out of stdout.
|
|
func verificationNotes(stdout string) []string {
|
|
var notes []string
|
|
for _, line := range strings.Split(stdout, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if after, ok := strings.CutPrefix(line, skillVerifyNotePrefix); ok {
|
|
notes = append(notes, after)
|
|
}
|
|
}
|
|
return notes
|
|
}
|
|
|
|
// verificationProblems splits a failed pass's stderr into its findings. Blank
|
|
// lines are dropped; everything else is the checker's own wording, which is
|
|
// what a repair round is given.
|
|
func verificationProblems(stderr string) []string {
|
|
var problems []string
|
|
for _, line := range strings.Split(stderr, "\n") {
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
problems = append(problems, line)
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// skillPythonVerifyCommand pipes the parser into the same interpreter a
|
|
// runtime skill call would use, and names the files to check on the command
|
|
// line. The list is explicit rather than a directory walk so the pass covers
|
|
// exactly the uploaded sources — never .venv, node_modules or anything else
|
|
// the agent created while installing.
|
|
//
|
|
// Auxiliary files are named after --optional. They are checked the same way,
|
|
// and what is found in them is reported rather than allowed to refuse the
|
|
// install.
|
|
func skillPythonVerifyCommand(skillDir string, entry, auxiliary []string) string {
|
|
venv := path.Join(skillDir, ".venv", "bin", "python")
|
|
quotedVenv := sandbox.ShellQuote(venv)
|
|
args := []string{sandbox.ShellQuote(skillDir)}
|
|
for _, rel := range entry {
|
|
args = append(args, sandbox.ShellQuote(rel))
|
|
}
|
|
if len(auxiliary) > 0 {
|
|
args = append(args, skillVerifyOptionalFlag)
|
|
for _, rel := range auxiliary {
|
|
args = append(args, sandbox.ShellQuote(rel))
|
|
}
|
|
}
|
|
// The script arrives base64-encoded on stdin: it contains quotes, newlines
|
|
// and backslashes, and a heredoc would still be at the mercy of whatever
|
|
// the remote exec layer does with multi-line command strings.
|
|
return fmt.Sprintf(
|
|
"if [ -x %s ]; then py=%s; else py=python3; fi; printf %%s '%s' | base64 -d | \"$py\" - %s",
|
|
quotedVenv, quotedVenv,
|
|
base64.StdEncoding.EncodeToString([]byte(skillPythonVerifier)),
|
|
strings.Join(args, " "),
|
|
)
|
|
}
|
|
|
|
// skillNodeVerifyCommand parses every JavaScript file and, when the bundle
|
|
// declares runtime dependencies, checks each one resolves. `node --check` is
|
|
// parse-only: it never runs the module body.
|
|
//
|
|
// A declared dependency that is missing exits with the repairable code: it is
|
|
// the one thing here another installer round can still put in place.
|
|
func skillNodeVerifyCommand(skillDir string, scripts, deps []string) string {
|
|
parts := make([]string, 0, 2)
|
|
if len(deps) > 0 {
|
|
quoted := make([]string, 0, len(deps))
|
|
for _, dep := range deps {
|
|
quoted = append(quoted, sandbox.ShellQuote(dep))
|
|
}
|
|
parts = append(parts, fmt.Sprintf(
|
|
"for d in %s; do [ -e %s/\"$d\" ] || "+
|
|
"{ echo \"package.json declares $d but node_modules/$d is missing\" >&2; exit %d; }; done",
|
|
strings.Join(quoted, " "), sandbox.ShellQuote(path.Join(skillDir, "node_modules")),
|
|
skillVerifyRepairableExit,
|
|
))
|
|
}
|
|
parts = append(parts, forEachScript(skillDir, scripts, "node --check \"$f\""))
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// skillShellVerifyCommand parses every shell script with the shell that would
|
|
// run it. `-n` reads the file and exits without executing any of it.
|
|
//
|
|
// bash, not sh: skills ship `#!/bin/bash` almost exclusively, and
|
|
// SkillInterpreterCommand runs them with bash for that reason. /bin/sh is dash
|
|
// on Debian, where an array literal, `function f()`, a C-style for loop and
|
|
// process substitution are all outright syntax errors — so checking with sh
|
|
// would refuse installs over scripts that run perfectly.
|
|
func skillShellVerifyCommand(skillDir string, scripts []string) string {
|
|
return "if command -v bash >/dev/null 2>&1; then parser=bash; else parser=sh; fi; " +
|
|
forEachScript(skillDir, scripts, `"$parser" -n "$f"`)
|
|
}
|
|
|
|
// skillVerifyOptionalFlag separates the files whose failure refuses the install
|
|
// from the ones whose failure is only reported. It is the checker's own argv
|
|
// contract; both sides must keep using this spelling.
|
|
const skillVerifyOptionalFlag = "--optional"
|
|
|
|
// auxiliaryScriptDirs are the directory names Python and JavaScript projects
|
|
// universally use for files that ship with a project without being part of
|
|
// what it offers. A skill's tests import pytest; its examples import whatever
|
|
// they illustrate. Nothing the skill exposes loads either, so neither may
|
|
// decide whether the skill installs.
|
|
var auxiliaryScriptDirs = map[string]struct{}{
|
|
"test": {}, "tests": {}, "testing": {}, "__tests__": {},
|
|
"example": {}, "examples": {}, "sample": {}, "samples": {},
|
|
"benchmark": {}, "benchmarks": {}, "fixtures": {},
|
|
"doc": {}, "docs": {},
|
|
}
|
|
|
|
// skillAuxiliaryScript reports whether a bundled path is one of those files.
|
|
//
|
|
// The rule is the naming convention the language communities already share,
|
|
// not a list grown one skill at a time: a directory the ecosystem reserves for
|
|
// tests, examples or docs, or a file name pytest and setuptools already treat
|
|
// as theirs.
|
|
func skillAuxiliaryScript(rel string) bool {
|
|
segments := strings.Split(path.Clean(rel), "/")
|
|
for _, segment := range segments[:len(segments)-1] {
|
|
if _, ok := auxiliaryScriptDirs[strings.ToLower(segment)]; ok {
|
|
return true
|
|
}
|
|
}
|
|
base := strings.ToLower(segments[len(segments)-1])
|
|
stem := strings.TrimSuffix(base, path.Ext(base))
|
|
return stem == "conftest" || stem == "setup" ||
|
|
strings.HasPrefix(stem, "test_") || strings.HasSuffix(stem, "_test")
|
|
}
|
|
|
|
// splitAuxiliaryScripts separates the files whose failure is a failed install
|
|
// from the ones whose failure is a note, preserving the caller's order.
|
|
func splitAuxiliaryScripts(scripts []string) (entry, auxiliary []string) {
|
|
for _, rel := range scripts {
|
|
if skillAuxiliaryScript(rel) {
|
|
auxiliary = append(auxiliary, rel)
|
|
continue
|
|
}
|
|
entry = append(entry, rel)
|
|
}
|
|
return entry, auxiliary
|
|
}
|
|
|
|
func forEachScript(skillDir string, scripts []string, check string) string {
|
|
quoted := make([]string, 0, len(scripts))
|
|
for _, rel := range scripts {
|
|
quoted = append(quoted, sandbox.ShellQuote(path.Join(skillDir, rel)))
|
|
}
|
|
return fmt.Sprintf("for f in %s; do %s || exit 1; done", strings.Join(quoted, " "), check)
|
|
}
|
|
|
|
// allScriptExtensions is every suffix the runtime knows how to execute, which
|
|
// is what makes a bundle file a script the model can name.
|
|
var allScriptExtensions = []string{".py", ".js", ".mjs", ".cjs", ".sh"}
|
|
|
|
// sortedScriptPaths returns the bundle's files with any of the given suffixes,
|
|
// in a stable order so the emitted command is deterministic.
|
|
func sortedScriptPaths(bundle *SkillBundle, suffixes ...string) []string {
|
|
if bundle == nil {
|
|
return nil
|
|
}
|
|
var matches []string
|
|
for rel := range bundle.Files {
|
|
for _, suffix := range suffixes {
|
|
if strings.HasSuffix(rel, suffix) {
|
|
matches = append(matches, rel)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(matches)
|
|
return matches
|
|
}
|
|
|
|
// nodeDependencyNames lists the runtime dependencies package.json declares.
|
|
// devDependencies are excluded: they are a build-time concern and the image
|
|
// is not asked to carry them.
|
|
func nodeDependencyNames(bundle *SkillBundle) []string {
|
|
if bundle == nil {
|
|
return nil
|
|
}
|
|
raw, ok := bundle.Files["package.json"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var manifest struct {
|
|
Dependencies map[string]string `json:"dependencies"`
|
|
}
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
// A package.json the installer could not read is the agent's problem
|
|
// to report, not a reason to fail verification on a parse we only
|
|
// needed for a nicer error message.
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(manifest.Dependencies))
|
|
for name := range manifest.Dependencies {
|
|
if strings.TrimSpace(name) != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func bundleHasPythonDeps(bundle *SkillBundle) bool {
|
|
if bundle == nil {
|
|
return false
|
|
}
|
|
_, req := bundle.Files["requirements.txt"]
|
|
_, pyproject := bundle.Files["pyproject.toml"]
|
|
return req || pyproject
|
|
}
|
|
|
|
func bundleHasNodeDeps(bundle *SkillBundle) bool {
|
|
if bundle == nil {
|
|
return false
|
|
}
|
|
_, ok := bundle.Files["package.json"]
|
|
return ok
|
|
}
|