mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { SettingScope } from '../config/settings.js';
|
|
import type { SkillActionResult } from './skillSettings.js';
|
|
import { Storage, loadSkillsFromDir } from '@google/gemini-cli-core';
|
|
import { cloneFromGit } from '../config/extensions/github.js';
|
|
import extract from 'extract-zip';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
|
|
/**
|
|
* Shared logic for building the core skill action message while allowing the
|
|
* caller to control how each scope and its path are rendered (e.g., bolding or
|
|
* dimming).
|
|
*
|
|
* This function ONLY returns the description of what happened. It is up to the
|
|
* caller to append any interface-specific guidance (like "Use /skills reload"
|
|
* or "Restart required").
|
|
*/
|
|
export function renderSkillActionFeedback(
|
|
result: SkillActionResult,
|
|
formatScope: (label: string, path: string) => string,
|
|
): string {
|
|
const { skillName, action, status, error } = result;
|
|
|
|
if (status === 'error') {
|
|
return (
|
|
error ||
|
|
`An error occurred while attempting to ${action} skill "${skillName}".`
|
|
);
|
|
}
|
|
|
|
if (status === 'no-op') {
|
|
return `Skill "${skillName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`;
|
|
}
|
|
|
|
const isEnable = action === 'enable';
|
|
const actionVerb = isEnable ? 'enabled' : 'disabled';
|
|
const preposition = isEnable
|
|
? 'by removing it from the disabled list in'
|
|
: 'by adding it to the disabled list in';
|
|
|
|
const formatScopeItem = (s: { scope: SettingScope; path: string }) => {
|
|
const label =
|
|
s.scope === SettingScope.Workspace ? 'project' : s.scope.toLowerCase();
|
|
return formatScope(label, s.path);
|
|
};
|
|
|
|
const totalAffectedScopes = [
|
|
...result.modifiedScopes,
|
|
...result.alreadyInStateScopes,
|
|
];
|
|
|
|
if (totalAffectedScopes.length === 2) {
|
|
const s1 = formatScopeItem(totalAffectedScopes[0]);
|
|
const s2 = formatScopeItem(totalAffectedScopes[1]);
|
|
|
|
if (isEnable) {
|
|
return `Skill "${skillName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`;
|
|
} else {
|
|
return `Skill "${skillName}" is now disabled in both ${s1} and ${s2} settings.`;
|
|
}
|
|
}
|
|
|
|
const s = formatScopeItem(totalAffectedScopes[0]);
|
|
return `Skill "${skillName}" ${actionVerb} ${preposition} ${s} settings.`;
|
|
}
|
|
|
|
/**
|
|
* Central logic for installing a skill from a remote URL or local path.
|
|
*/
|
|
export async function installSkill(
|
|
source: string,
|
|
scope: 'user' | 'workspace',
|
|
subpath: string | undefined,
|
|
onLog: (msg: string) => void,
|
|
): Promise<Array<{ name: string; location: string }>> {
|
|
let sourcePath = source;
|
|
let tempDirToClean: string | undefined = undefined;
|
|
|
|
const isGitUrl =
|
|
source.startsWith('git@') ||
|
|
source.startsWith('http://') ||
|
|
source.startsWith('https://');
|
|
|
|
const isSkillFile = source.toLowerCase().endsWith('.skill');
|
|
|
|
if (isGitUrl) {
|
|
tempDirToClean = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-skill-'));
|
|
sourcePath = tempDirToClean;
|
|
|
|
onLog(`Cloning skill from ${source}...`);
|
|
// Reuse existing robust git cloning utility from extension manager.
|
|
await cloneFromGit(
|
|
{
|
|
source,
|
|
type: 'git',
|
|
},
|
|
tempDirToClean,
|
|
);
|
|
} else if (isSkillFile) {
|
|
tempDirToClean = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-skill-'));
|
|
sourcePath = tempDirToClean;
|
|
|
|
onLog(`Extracting skill from ${source}...`);
|
|
await extract(path.resolve(source), { dir: tempDirToClean });
|
|
}
|
|
|
|
// If a subpath is provided, resolve it against the cloned/local root.
|
|
if (subpath) {
|
|
sourcePath = path.join(sourcePath, subpath);
|
|
}
|
|
|
|
sourcePath = path.resolve(sourcePath);
|
|
|
|
// Quick security check to prevent directory traversal out of temp dir when cloning
|
|
if (tempDirToClean && !sourcePath.startsWith(path.resolve(tempDirToClean))) {
|
|
if (tempDirToClean) {
|
|
await fs.rm(tempDirToClean, { recursive: true, force: true });
|
|
}
|
|
throw new Error('Invalid path: Directory traversal not allowed.');
|
|
}
|
|
|
|
onLog(`Searching for skills in ${sourcePath}...`);
|
|
const skills = await loadSkillsFromDir(sourcePath);
|
|
|
|
if (skills.length === 0) {
|
|
if (tempDirToClean) {
|
|
await fs.rm(tempDirToClean, { recursive: true, force: true });
|
|
}
|
|
throw new Error(
|
|
`No valid skills found in ${source}${subpath ? ` at path "${subpath}"` : ''}. Ensure a SKILL.md file exists with valid frontmatter.`,
|
|
);
|
|
}
|
|
|
|
const workspaceDir = process.cwd();
|
|
const storage = new Storage(workspaceDir);
|
|
const targetDir =
|
|
scope === 'workspace'
|
|
? storage.getProjectSkillsDir()
|
|
: Storage.getUserSkillsDir();
|
|
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
|
|
const installedSkills: Array<{ name: string; location: string }> = [];
|
|
|
|
for (const skill of skills) {
|
|
const skillName = skill.name;
|
|
const skillDir = path.dirname(skill.location);
|
|
const destPath = path.join(targetDir, skillName);
|
|
|
|
const exists = await fs.stat(destPath).catch(() => null);
|
|
if (exists) {
|
|
onLog(`Skill "${skillName}" already exists. Overwriting...`);
|
|
await fs.rm(destPath, { recursive: true, force: true });
|
|
}
|
|
|
|
await fs.cp(skillDir, destPath, { recursive: true });
|
|
installedSkills.push({ name: skillName, location: destPath });
|
|
}
|
|
|
|
if (tempDirToClean) {
|
|
await fs.rm(tempDirToClean, { recursive: true, force: true });
|
|
}
|
|
|
|
return installedSkills;
|
|
}
|
|
|
|
/**
|
|
* Central logic for uninstalling a skill by name.
|
|
*/
|
|
export async function uninstallSkill(
|
|
name: string,
|
|
scope: 'user' | 'workspace',
|
|
): Promise<{ location: string } | null> {
|
|
const workspaceDir = process.cwd();
|
|
const storage = new Storage(workspaceDir);
|
|
const targetDir =
|
|
scope === 'workspace'
|
|
? storage.getProjectSkillsDir()
|
|
: Storage.getUserSkillsDir();
|
|
|
|
const skillPath = path.join(targetDir, name);
|
|
|
|
const exists = await fs.stat(skillPath).catch(() => null);
|
|
|
|
if (!exists) {
|
|
return null;
|
|
}
|
|
|
|
await fs.rm(skillPath, { recursive: true, force: true });
|
|
return { location: skillPath };
|
|
}
|