diff --git a/.agents/skills/s-fc3/README.md b/.agents/skills/s-fc3/README.md index d5779bf9..0bee89c0 100644 --- a/.agents/skills/s-fc3/README.md +++ b/.agents/skills/s-fc3/README.md @@ -11,6 +11,26 @@ npm i -g @serverless-devs/s s config add # 配阿里云 AccessKey ``` +## 安装本 Skill + +`fc3` 组件内置了 `skill` 命令,可一键把本 skill 安装到主流 Agent 工具的 skills 目录: + +```bash +# 安装到全部支持的工具(用户级,默认):claude / codex / cursor / qoder / agents +s cli fc3 skill install + +# 只装指定工具 +s cli fc3 skill install --tools claude,codex + +# 装到当前项目(写入 ./.claude/skills 等) +s cli fc3 skill install --project + +# 覆盖更新已安装版本 +s cli fc3 skill update +``` + +安装位置统一为 `<工具目录>/skills/s-fc3/`(全局取用户主目录,`--project` 取当前目录)。`install` 遇到已存在目标会跳过(除非 `--force`),`update` 始终覆盖。 + ## 使用示例 以下对话来自与 Agent 的真实交互(杭州区域,`access: default`): @@ -121,6 +141,7 @@ Agent 以表格呈现:运行时、CPU/内存/磁盘、超时、代码大小、 | `s session` | 会话管理(创建/列表/更新/删除) | | `s sync` | 线上配置拉到本地 | | `s2tos3` | FC2 格式转 FC3 | +| `s skill` | 把本 skill 安装/更新到主流 Agent 工具目录(本地操作,无需凭证) | | fc3-domain | 自定义域名(多函数路由、HTTPS、TLS、WAF) | 详细用法见 [SKILL.md](./SKILL.md)。 \ No newline at end of file diff --git a/.agents/skills/s-fc3/SKILL.md b/.agents/skills/s-fc3/SKILL.md index 77069cdb..a4a40701 100644 --- a/.agents/skills/s-fc3/SKILL.md +++ b/.agents/skills/s-fc3/SKILL.md @@ -34,6 +34,19 @@ npm i -g @serverless-devs/s s config add ``` +### 安装本 Skill 到 Agent 工具 + +`fc3` 组件自带 `skill` 命令,可把本 skill 安装到主流工具(claude/codex/cursor/qoder/agents)的 `skills/s-fc3/` 目录: + +```bash +s cli fc3 skill install # 全部工具,用户级(默认) +s cli fc3 skill install --tools claude,codex # 指定工具 +s cli fc3 skill install --project # 装到当前项目 +s cli fc3 skill update # 覆盖更新 +``` + +`install` 已存在则跳过(`--force` 覆盖);`update` 始终覆盖。 + ### 非默认 FC API 接入点 - 使用 `FC_CLIENT_CUSTOM_ENDPOINT`。 diff --git a/CLAUDE.md b/CLAUDE.md index 67c59689..752f66f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ FC3 is the Serverless Devs component for Alibaba Cloud Function Compute 3.0, pro ## Testing -**Current Status**: 986 tests total, 986 passing, 2 skipped (integration tests require cloud credentials) +**Current Status**: 1138 tests total, 1136 passing, 2 skipped (integration tests require cloud credentials) **Run tests**: `npm test` **Coverage**: Run with `--coverage` flag @@ -64,6 +64,7 @@ See `docs/architecture.md` for detailed diagrams. - ProvisionConfig/ScalingConfig array handling - LLM metrics in logConfig - Logs command: multi-topic search (FCLogs + FCInstanceEvents) for --instance-id, SLS field-specific query syntax +- `skill` command: install/update the bundled `s-fc3` skill into agent tools (claude/codex/cursor/qoder/agents), global or project scope; local operation, no credentials ## Development Workflow diff --git a/__tests__/e2e/ci-mac-linux.sh b/__tests__/e2e/ci-mac-linux.sh index 95255047..aaaed30e 100755 --- a/__tests__/e2e/ci-mac-linux.sh +++ b/__tests__/e2e/ci-mac-linux.sh @@ -7,6 +7,9 @@ set -e set -v +echo " ********* skill install/update (offline) *********" +cd skill && bash ./run && cd .. + if [[ $(uname -s) == "Linux" ]]; then # echo "test trigger" # cd trigger && ./run diff --git a/__tests__/e2e/ci-windows.ps1 b/__tests__/e2e/ci-windows.ps1 index 59d6fb65..cecf9c9c 100755 --- a/__tests__/e2e/ci-windows.ps1 +++ b/__tests__/e2e/ci-windows.ps1 @@ -6,6 +6,9 @@ $ErrorActionPreference = "Stop" # $env:OS="WIN" # $env:PROCESSOR_ARCHITECTURE="NT" +Write-Host " ********* skill install/update (offline) *********" +cd skill; ./run-windows.ps1; cd .. + Write-Host "[PERMISSIONS_TEST] Testing nodejs permissions validation ..." cd nodejs Write-Host "[PERMISSIONS_TEST] Running permissions validation test..." -ForegroundColor Green diff --git a/__tests__/e2e/ci.sh b/__tests__/e2e/ci.sh index ba244295..5162b522 100755 --- a/__tests__/e2e/ci.sh +++ b/__tests__/e2e/ci.sh @@ -6,6 +6,9 @@ set -e set -v +echo " ********* skill install/update (offline) *********" +cd skill && bash ./run && cd - + echo "test custom-domain" cd custom-domain s deploy -y diff --git a/__tests__/e2e/skill/run b/__tests__/e2e/skill/run new file mode 100755 index 00000000..08f84ecb --- /dev/null +++ b/__tests__/e2e/skill/run @@ -0,0 +1,107 @@ +#!/bin/bash + +# E2E for `fc3 skill install/update`. +# Fully sandboxed and offline: installs the bundled s-fc3 skill into temp +# project/home roots and asserts the files land where each tool expects them. +# Needs NO cloud credentials. +# +# The local component build is loaded through a minimal s.yaml that points at +# the repo root (component: ) and is driven with `s skill ...`. We do +# NOT use `s cli ...`: on Windows the CLI joins that absolute path +# into its per-run log directory, which breaks when cwd and the repo live on +# different drives. The s.yaml path mirrors how every other e2e drives the CLI. + +set -e +set -v + +current_dir="$PWD" +fc3_dir=$(dirname $(dirname $(dirname "$current_dir"))) +echo "fc3 root dir: $fc3_dir" + +TOOLS="claude codex cursor qoder agents" + +# Sandbox roots; cleaned up on exit. +PROJECT_ROOT=$(mktemp -d) +HOME_ROOT=$(mktemp -d) +WORK_ROOT=$(mktemp -d) +FILTER_ROOT=$(mktemp -d) +cleanup() { + rm -rf "$PROJECT_ROOT" "$HOME_ROOT" "$WORK_ROOT" "$FILTER_ROOT" +} +trap cleanup EXIT + +# write_syaml — drop a minimal offline s.yaml that loads the local build. +write_syaml() { + cat > "$1/s.yaml" < — fail the script if the file is missing. +assert_file() { + if [ ! -f "$1" ]; then + echo "ASSERT FAILED: expected file not found: $1" + exit 1 + fi + echo "OK: $1" +} + +assert_missing() { + if [ -e "$1" ]; then + echo "ASSERT FAILED: expected path to be gone: $1" + exit 1 + fi + echo "OK (absent): $1" +} + +skill_path() { + # + echo "$1/.$2/skills/s-fc3/SKILL.md" +} + +write_syaml "$PROJECT_ROOT" +write_syaml "$WORK_ROOT" +write_syaml "$FILTER_ROOT" + +echo "=== project-scope install (all tools) ===" +cd "$PROJECT_ROOT" +s skill install --project +for t in $TOOLS; do + assert_file "$(skill_path "$PROJECT_ROOT" "$t")" +done + +echo "=== install is idempotent: existing target is skipped, local edits preserved ===" +MARKER="$PROJECT_ROOT/.claude/skills/s-fc3/LOCAL_MARKER" +echo "keep-me" > "$MARKER" +s skill install --project --tools claude +assert_file "$MARKER" + +echo "=== --force overwrites and cleans stale files ===" +s skill install --project --tools claude --force +assert_missing "$MARKER" +assert_file "$(skill_path "$PROJECT_ROOT" claude)" + +echo "=== update overwrites existing installations ===" +echo "stale" > "$MARKER" +s skill update --project --tools claude +assert_missing "$MARKER" +assert_file "$(skill_path "$PROJECT_ROOT" claude)" + +echo "=== --tools filter installs only the requested tools ===" +cd "$FILTER_ROOT" +s skill install --project --tools codex +assert_file "$(skill_path "$FILTER_ROOT" codex)" +assert_missing "$FILTER_ROOT/.cursor/skills/s-fc3" + +echo "=== global-scope install writes under the (sandboxed) home directory ===" +cd "$WORK_ROOT" +HOME="$HOME_ROOT" s skill install --global --tools claude,codex +assert_file "$(skill_path "$HOME_ROOT" claude)" +assert_file "$(skill_path "$HOME_ROOT" codex)" + +echo "=== skill e2e passed ===" diff --git a/__tests__/e2e/skill/run-windows.ps1 b/__tests__/e2e/skill/run-windows.ps1 new file mode 100644 index 00000000..384ffd02 --- /dev/null +++ b/__tests__/e2e/skill/run-windows.ps1 @@ -0,0 +1,117 @@ +# E2E for `fc3 skill install/update` on Windows. +# Fully sandboxed and offline; needs NO cloud credentials. +# +# The local component build is loaded through a minimal s.yaml that points at +# the repo root (component: ) and is driven with `s skill ...`. We do +# NOT use `s cli ...`: on Windows the CLI joins that absolute path +# into its per-run log directory, which breaks when cwd (the temp sandbox on +# C:) and the repo (on D:) live on different drives. + +$ErrorActionPreference = "Stop" + +$current_dir = $PWD.Path +$fc3_dir = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $current_dir)) +Write-Host "fc3 root dir: $fc3_dir" + +$tools = @("claude", "codex", "cursor", "qoder", "agents") + +$projectRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("fc3-skill-proj-" + [System.Guid]::NewGuid().ToString("N")) +$homeRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("fc3-skill-home-" + [System.Guid]::NewGuid().ToString("N")) +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("fc3-skill-work-" + [System.Guid]::NewGuid().ToString("N")) +$filterRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("fc3-skill-filter-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $projectRoot | Out-Null +New-Item -ItemType Directory -Force -Path $homeRoot | Out-Null +New-Item -ItemType Directory -Force -Path $workRoot | Out-Null +New-Item -ItemType Directory -Force -Path $filterRoot | Out-Null + +# Drop a minimal offline s.yaml that loads the local build. The component path +# is single-quoted so backslashes stay literal in YAML. +function Write-SYaml($dir) { + $yaml = @" +edition: 3.0.0 +name: skill-e2e +resources: + fc3: + component: '$fc3_dir' + props: {} +"@ + Set-Content -Path (Join-Path $dir "s.yaml") -Value $yaml -Encoding utf8 +} + +function Assert-File($p) { + if (-not (Test-Path -PathType Leaf $p)) { + Write-Host "ASSERT FAILED: expected file not found: $p" -ForegroundColor Red + exit 1 + } + Write-Host "OK: $p" +} + +function Assert-Missing($p) { + if (Test-Path $p) { + Write-Host "ASSERT FAILED: expected path to be gone: $p" -ForegroundColor Red + exit 1 + } + Write-Host "OK (absent): $p" +} + +function Skill-Path($root, $tool) { + return (Join-Path $root ".$tool\skills\s-fc3\SKILL.md") +} + +Write-SYaml $projectRoot +Write-SYaml $workRoot +Write-SYaml $filterRoot + +try { + Write-Host "=== project-scope install (all tools) ===" + Set-Location $projectRoot + s skill install --project + foreach ($t in $tools) { Assert-File (Skill-Path $projectRoot $t) } + + Write-Host "=== install is idempotent: existing target is skipped ===" + $marker = Join-Path $projectRoot ".claude\skills\s-fc3\LOCAL_MARKER" + Set-Content -Path $marker -Value "keep-me" + s skill install --project --tools claude + Assert-File $marker + + Write-Host "=== --force overwrites and cleans stale files ===" + s skill install --project --tools claude --force + Assert-Missing $marker + Assert-File (Skill-Path $projectRoot "claude") + + Write-Host "=== update overwrites existing installations ===" + Set-Content -Path $marker -Value "stale" + s skill update --project --tools claude + Assert-Missing $marker + Assert-File (Skill-Path $projectRoot "claude") + + Write-Host "=== --tools filter installs only the requested tools ===" + Set-Location $filterRoot + s skill install --project --tools codex + Assert-File (Skill-Path $filterRoot "codex") + Assert-Missing (Join-Path $filterRoot ".cursor\skills\s-fc3") + + Write-Host "=== global-scope install writes under the sandboxed home directory ===" + Set-Location $workRoot + $oldHome = $env:USERPROFILE + try { + $env:USERPROFILE = $homeRoot + s skill install --global --tools "claude,codex" + } finally { + $env:USERPROFILE = $oldHome + } + Assert-File (Skill-Path $homeRoot "claude") + Assert-File (Skill-Path $homeRoot "codex") + + Write-Host "=== skill e2e passed ===" +} finally { + # Restore cwd to where this script started (the `skill` dir) so the caller's + # `cd ..` returns to the e2e root. Restoring to $fc3_dir instead would leave + # the caller one level too high. $current_dir is outside the temp sandboxes, + # so it is safe to sit here while they are removed. + Set-Location $current_dir + Remove-Item -Recurse -Force $projectRoot -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $homeRoot -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $workRoot -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $filterRoot -ErrorAction SilentlyContinue +} diff --git a/__tests__/ut/commands/skill/skill.test.ts b/__tests__/ut/commands/skill/skill.test.ts new file mode 100644 index 00000000..857a0a7f --- /dev/null +++ b/__tests__/ut/commands/skill/skill.test.ts @@ -0,0 +1,167 @@ +import * as path from 'path'; +import * as os from 'os'; +import fs from 'fs-extra'; +import Skill from '../../../../src/subCommands/skill'; +import { + resolveSkillSource, + targetDir, + installOne, +} from '../../../../src/subCommands/skill/installer'; +import { ALL_TOOLS, TOOL_DIRS, SKILL_NAME } from '../../../../src/subCommands/skill/constants'; + +jest.mock('../../../../src/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +// os.homedir is non-configurable for spyOn, so mock the module (keeping tmpdir real). +jest.mock('os', () => { + const actual = jest.requireActual('os'); + return { ...actual, homedir: jest.fn(() => actual.homedir()) }; +}); + +// Isolated fake home + project roots so tests never touch the real ~/.claude etc. +let homeRoot: string; +let projectRoot: string; + +beforeEach(() => { + homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fc3-skill-home-')); + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fc3-skill-proj-')); + (os.homedir as jest.Mock).mockReturnValue(homeRoot); + jest.spyOn(process, 'cwd').mockReturnValue(projectRoot); +}); + +afterEach(() => { + jest.restoreAllMocks(); + fs.removeSync(homeRoot); + fs.removeSync(projectRoot); +}); + +const inputs = (args: string[]) => ({ args } as any); + +describe('installer', () => { + test('resolveSkillSource finds a directory containing SKILL.md', () => { + // process.cwd is mocked, but the repo .agents source resolves via __dirname candidate. + jest.spyOn(process, 'cwd').mockRestore(); + const source = resolveSkillSource(); + expect(fs.existsSync(path.join(source, 'SKILL.md'))).toBe(true); + }); + + test('targetDir builds //skills/ for every tool', () => { + for (const tool of ALL_TOOLS) { + expect(targetDir(tool, 'global')).toBe( + path.join(homeRoot, TOOL_DIRS[tool], 'skills', SKILL_NAME), + ); + expect(targetDir(tool, 'project')).toBe( + path.join(projectRoot, TOOL_DIRS[tool], 'skills', SKILL_NAME), + ); + } + }); + + test('targetDir throws on an unknown tool', () => { + expect(() => targetDir('nope', 'global')).toThrow(/Unsupported tool/); + }); + + describe('installOne', () => { + let source: string; + beforeEach(() => { + source = fs.mkdtempSync(path.join(os.tmpdir(), 'fc3-skill-src-')); + fs.writeFileSync(path.join(source, 'SKILL.md'), '# s-fc3\n'); + }); + afterEach(() => fs.removeSync(source)); + + test('install into an empty target reports "installed" and copies files', () => { + const result = installOne(source, 'claude', 'global', 'install', false); + expect(result.status).toBe('installed'); + expect(fs.existsSync(path.join(result.target, 'SKILL.md'))).toBe(true); + }); + + test('install skips an existing target without force', () => { + installOne(source, 'claude', 'global', 'install', false); + // mutate the installed copy so we can detect a (non-)overwrite + const marker = path.join(targetDir('claude', 'global'), 'MARKER'); + fs.writeFileSync(marker, 'keep'); + + const result = installOne(source, 'claude', 'global', 'install', false); + expect(result.status).toBe('skipped'); + expect(fs.existsSync(marker)).toBe(true); + }); + + test('install with force overwrites and reports "overwritten"', () => { + installOne(source, 'claude', 'global', 'install', false); + const marker = path.join(targetDir('claude', 'global'), 'MARKER'); + fs.writeFileSync(marker, 'stale'); + + const result = installOne(source, 'claude', 'global', 'install', true); + expect(result.status).toBe('overwritten'); + expect(fs.existsSync(marker)).toBe(false); // stale file cleaned up + expect(fs.existsSync(path.join(result.target, 'SKILL.md'))).toBe(true); + }); + + test('update overwrites an existing target and reports "updated"', () => { + installOne(source, 'qoder', 'project', 'install', false); + const result = installOne(source, 'qoder', 'project', 'update', false); + expect(result.status).toBe('updated'); + }); + }); +}); + +describe('Skill command', () => { + test('throws on missing/unknown subcommand', () => { + expect(() => new Skill(inputs([]))).toThrow(/not found/); + expect(() => new Skill(inputs(['bogus']))).toThrow(/not found/); + }); + + test('throws on unknown --tools value', () => { + expect(() => new Skill(inputs(['install', '--tools', 'claude,unknown']))).toThrow( + /Unknown tool/, + ); + }); + + test('install (default) targets all tools in the global scope', async () => { + const results = await new Skill(inputs(['install'])).install(); + expect(results).toHaveLength(ALL_TOOLS.length); + expect(results.every((r) => r.scope === 'global')).toBe(true); + expect(results.every((r) => r.status === 'installed')).toBe(true); + for (const tool of ALL_TOOLS) { + expect( + fs.existsSync(path.join(homeRoot, TOOL_DIRS[tool], 'skills', SKILL_NAME, 'SKILL.md')), + ).toBe(true); + } + }); + + test('install with --tools filter installs only those tools', async () => { + const results = await new Skill(inputs(['install', '--tools', 'claude,codex'])).install(); + expect(results.map((r) => r.tool).sort()).toEqual(['claude', 'codex']); + }); + + test('--tools accepts whitespace-separated values (e.g. PowerShell array join)', async () => { + const results = await new Skill(inputs(['install', '--tools', 'claude codex'])).install(); + expect(results.map((r) => r.tool).sort()).toEqual(['claude', 'codex']); + }); + + test('install then install again skips (idempotent without force)', async () => { + await new Skill(inputs(['install', '--tools', 'cursor'])).install(); + const again = await new Skill(inputs(['install', '--tools', 'cursor'])).install(); + expect(again[0].status).toBe('skipped'); + }); + + test('update overwrites existing installations', async () => { + await new Skill(inputs(['install', '--tools', 'cursor'])).install(); + const results = await new Skill(inputs(['update', '--tools', 'cursor'])).update(); + expect(results[0].status).toBe('updated'); + }); + + test('--global --project installs into both scopes', async () => { + const results = await new Skill( + inputs(['install', '--tools', 'agents', '--global', '--project']), + ).install(); + expect(results.map((r) => r.scope).sort()).toEqual(['global', 'project']); + expect(fs.existsSync(path.join(homeRoot, '.agents', 'skills', SKILL_NAME, 'SKILL.md'))).toBe( + true, + ); + expect(fs.existsSync(path.join(projectRoot, '.agents', 'skills', SKILL_NAME, 'SKILL.md'))).toBe( + true, + ); + }); +}); diff --git a/docs/CONTRIB.md b/docs/CONTRIB.md index f40750ff..1b5d83b3 100644 --- a/docs/CONTRIB.md +++ b/docs/CONTRIB.md @@ -103,12 +103,15 @@ Types: feat, fix, refactor, docs, test, chore, perf, ci ``` __tests__/ -├── ut/ # Unit tests +├── ut/ # Unit tests (Jest) │ ├── base_test.ts │ ├── deploy_test.ts │ └── ... -└── it/ # Integration tests - └── deploy_test.ts +├── it/ # Integration tests (Jest, needs cloud credentials) +│ └── deploy_test.ts +└── e2e/ # End-to-end tests (shell scripts driving the `s` CLI) + ├── skill/ # `skill` install/update — sandboxed, no credentials + └── ... ``` #### Running Tests @@ -125,6 +128,9 @@ npm run test:it # Update snapshots npx jest --updateSnapshot + +# Run the skill install/update E2E (offline, no credentials; requires the `s` CLI + `npm run build`) +make test-skill ``` #### Test Naming Convention @@ -172,20 +178,21 @@ src/ ## Key Subcommands -| Command | Description | -| --------- | ----------------------------- | -| `deploy` | Deploy functions and triggers | -| `build` | Build function code/packages | -| `local` | Local development and testing | -| `invoke` | Invoke functions remotely | -| `info` | Query function information | -| `logs` | Query function logs | -| `remove` | Remove deployed resources | -| `plan` | Show deployment plan | -| `layer` | Manage function layers | -| `version` | Version management | -| `alias` | Alias management | -| `sync` | Sync configurations | +| Command | Description | +| --------- | --------------------------------------------------------------------------------- | +| `deploy` | Deploy functions and triggers | +| `build` | Build function code/packages | +| `local` | Local development and testing | +| `invoke` | Invoke functions remotely | +| `info` | Query function information | +| `logs` | Query function logs | +| `remove` | Remove deployed resources | +| `plan` | Show deployment plan | +| `layer` | Manage function layers | +| `version` | Version management | +| `alias` | Alias management | +| `sync` | Sync configurations | +| `skill` | Install/update the bundled `s-fc3` skill into agent tools (local, no credentials) | ## Debugging diff --git a/docs/architecture.md b/docs/architecture.md index 90891d37..df2ca12a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,6 +31,7 @@ graph TB S[version 版本] T[model 模型] U[s2tos3 转换] + V[skill 技能安装] end B --> E @@ -50,6 +51,7 @@ graph TB B --> S B --> T B --> U + B --> V subgraph "资源管理模块" V[FC 函数计算] @@ -166,6 +168,7 @@ src/ - `version()` - 版本管理 - `model()` - 模型管理 - `s2tos3()` - 配置转换 +- `skill()` - 安装/更新 `s-fc3` skill 到主流 Agent 工具(本地操作,无需凭证,不走 `handlePreRun`) ### 2. 基础模块 (base.ts) @@ -218,6 +221,16 @@ src/ - 自定义运行时 (`customLocalStart.ts`) - 自定义容器 (`customContainerLocalStart.ts`) +#### 3.4 技能安装模块 (skill/) + +- **功能**: 把随组件打包的 `s-fc3` skill 安装/更新到主流 Agent 工具目录,纯本地操作,无需云凭证 +- **核心文件**: + - `constants.ts` - 工具 → 目录映射(`claude/codex/cursor/qoder/agents`,统一约定 `<工具目录>/skills/s-fc3/`) + - `installer.ts` - skill 源解析(`dist/skills` → 仓库 `.agents` → cwd `.agents`)与拷贝逻辑(纯函数,易测) + - `index.ts` - 参数解析(`--tools`、`--global`、`--project`、`--force`)与 `install`/`update` 派发 +- **语义**: `install` 遇到已存在目标跳过(除非 `--force`);`update` 始终覆盖 +- **打包**: skill 源文件在 `.agents/skills/s-fc3`,由 `prebuild`/`prewatch` 脚本拷贝进 `dist/skills/s-fc3`,随 npm 包发布 + ### 4. 资源管理模块 (resources/) #### 4.1 FC 函数计算 (fc/) diff --git a/makefile b/makefile index b9583a42..c5ce4687 100644 --- a/makefile +++ b/makefile @@ -26,7 +26,10 @@ test-custom-container: test-apt: cd __tests__/e2e/apt && bash run && cd - -test: test-nodejs test-python test-java test-go test-php test-custom test-custom-container test-apt +test-skill: + cd __tests__/e2e/skill && bash run && cd - + +test: test-skill test-nodejs test-python test-java test-go test-php test-custom test-custom-container test-apt echo "all test done!" release-dev: diff --git a/package.json b/package.json index 67ed7cb2..abb1789d 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "dist/index.js", "scripts": { "generate-schema": "npx typescript-json-schema ./src/interface/index.ts IProps --required -o ./src/schema.json", - "prewatch": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/schema.json','dist/schema.json')\"", - "prebuild": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/schema.json','dist/schema.json')\"", + "prewatch": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/schema.json','dist/schema.json');fs.cpSync('.agents/skills/s-fc3','dist/skills/s-fc3',{recursive:true})\"", + "prebuild": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/schema.json','dist/schema.json');fs.cpSync('.agents/skills/s-fc3','dist/skills/s-fc3',{recursive:true})\"", "watch": "npx tsc -w -p tsconfig.json", "start": "npm run watch", "build": "ncc build src/index.ts -m -o dist", diff --git a/publish.yaml b/publish.yaml index bfb5a21f..b6502f5b 100644 --- a/publish.yaml +++ b/publish.yaml @@ -35,6 +35,7 @@ Commands: info: 查看函数详情 sync: 线上资源同步到本地 s2tos3: fc2.0 yaml 转 fc3.0 yaml + skill: 安装/更新 s-fc3 skill 到主流工具(claude/codex/cursor/qoder/agents) Parameters: type: object diff --git a/src/commands-help/index.ts b/src/commands-help/index.ts index c32d4f0e..1b18d28f 100644 --- a/src/commands-help/index.ts +++ b/src/commands-help/index.ts @@ -18,6 +18,7 @@ import session from './session'; import scaling from './scaling'; import list from './list'; import model from './model'; +import skill from './skill'; export default { deploy, @@ -40,4 +41,5 @@ export default { scaling, list, model, + skill, }; diff --git a/src/commands-help/skill.ts b/src/commands-help/skill.ts new file mode 100644 index 00000000..fb3b0670 --- /dev/null +++ b/src/commands-help/skill.ts @@ -0,0 +1,46 @@ +const toolOption = [ + '--tools ', + 'Comma-separated target tools: claude,codex,cursor,qoder,agents (default: all)', +]; +const globalOption = ['--global', 'Install into the user home directory (default scope)']; +const projectOption = ['--project', 'Install into the current project directory']; + +export default { + help: { + description: 'Install or update the bundled s-fc3 skill into mainstream agent tools', + summary: 'Install/update the s-fc3 skill', + }, + subCommands: { + install: { + help: { + description: `Install the s-fc3 skill into the target tools' skills directory. +Existing installations are skipped unless --force is passed. + +Examples: + $ s cli fc3 skill install + $ s cli fc3 skill install --tools claude,codex + $ s cli fc3 skill install --project + $ s cli fc3 skill install --global --project --force`, + summary: 'Install the s-fc3 skill (skips existing)', + option: [ + toolOption, + globalOption, + projectOption, + ['--force', 'Overwrite an existing installation'], + ], + }, + }, + update: { + help: { + description: `Update (overwrite) the s-fc3 skill in the target tools' skills directory. + +Examples: + $ s cli fc3 skill update + $ s cli fc3 skill update --tools qoder + $ s cli fc3 skill update --global --project`, + summary: 'Update the s-fc3 skill (overwrites)', + option: [toolOption, globalOption, projectOption], + }, + }, + }, +}; diff --git a/src/index.ts b/src/index.ts index 93f69dd4..fe222965 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import List from './subCommands/list'; import { SCHEMA_FILE_PATH } from './constant'; import { checkDockerIsOK, isAppCenter, isYunXiao } from './utils'; import { Model } from './subCommands/model'; +import Skill from './subCommands/skill'; (process as any).noDeprecation = true; @@ -204,6 +205,13 @@ export default class Fc extends Base { return await model[model.subCommand](); } + // 安装/更新 s-fc3 skill 到主流工具目录(本地操作,无需凭证) + public async skill(inputs: IInputs) { + const skill = new Skill(inputs); + logger.debug(`skill subCommand: ${skill.subCommand}`); + return await skill[skill.subCommand](); + } + public async getSchema(inputs: IInputs) { logger.debug(`getSchema: ${JSON.stringify(inputs)}`); return fs.readFileSync(SCHEMA_FILE_PATH, 'utf-8'); diff --git a/src/subCommands/skill/constants.ts b/src/subCommands/skill/constants.ts new file mode 100644 index 00000000..1f64f863 --- /dev/null +++ b/src/subCommands/skill/constants.ts @@ -0,0 +1,24 @@ +/** + * The skill bundled with this component. Lives at `.agents/skills/` + * in the repo and is copied to `dist/skills/` at build time. + */ +export const SKILL_NAME = 's-fc3'; + +/** + * Mapping from a supported tool to the directory (relative to the install root) + * where that tool discovers skills. Every mainstream tool uses the same + * `/skills//` convention, so installing is uniform. + */ +export const TOOL_DIRS: Record = { + claude: '.claude', + codex: '.codex', + cursor: '.cursor', + qoder: '.qoder', + agents: '.agents', +}; + +/** All tools targeted when the user does not pass `--tools`. */ +export const ALL_TOOLS = Object.keys(TOOL_DIRS); + +/** Sub-directory under each tool dir that holds skills. */ +export const SKILLS_SUBDIR = 'skills'; diff --git a/src/subCommands/skill/index.ts b/src/subCommands/skill/index.ts new file mode 100644 index 00000000..850aa1e8 --- /dev/null +++ b/src/subCommands/skill/index.ts @@ -0,0 +1,106 @@ +import _ from 'lodash'; +import { parseArgv } from '@serverless-devs/utils'; +import logger from '../../logger'; +import commandsHelp from '../../commands-help/skill'; +import { ALL_TOOLS } from './constants'; +import { InstallMode, InstallResult, installOne, resolveSkillSource } from './installer'; + +const commandsList = Object.keys(commandsHelp.subCommands); + +export default class Skill { + readonly subCommand: InstallMode; + private tools: string[]; + private scopes: Array<'global' | 'project'>; + private force: boolean; + + constructor(inputs: { args?: string[] }) { + const opts = parseArgv(inputs.args, { + alias: { help: 'h' }, + boolean: ['help', 'global', 'project', 'force'], + string: ['tools'], + }); + logger.debug(`skill opts: ${JSON.stringify(opts)}`); + + const subCommand = _.get(opts, '_[0]'); + if (!subCommand || !commandsList.includes(subCommand)) { + throw new Error( + `Command "${subCommand}" not found, Please use "s cli fc3 skill -h" to query how to use the command`, + ); + } + this.subCommand = subCommand as InstallMode; + + this.tools = this.parseTools(opts.tools); + this.scopes = this.parseScopes(!!opts.global, !!opts.project); + // `update` implies overwrite; `force` only affects `install`. + this.force = !!opts.force; + } + + async install(): Promise { + return this.run('install'); + } + + async update(): Promise { + return this.run('update'); + } + + private parseTools(raw?: string): string[] { + if (_.isEmpty(raw)) { + return [...ALL_TOOLS]; + } + // Split on commas or whitespace: some shells (e.g. PowerShell) turn + // `--tools claude,codex` into a single space-joined argument. + const requested = raw + .split(/[\s,]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + const unknown = requested.filter((t) => !ALL_TOOLS.includes(t)); + if (unknown.length > 0) { + throw new Error(`Unknown tool(s): ${unknown.join(', ')}. Supported: ${ALL_TOOLS.join(', ')}`); + } + return _.uniq(requested); + } + + private parseScopes(global: boolean, project: boolean): Array<'global' | 'project'> { + if (global && project) { + return ['global', 'project']; + } + if (project) { + return ['project']; + } + // Default to global when nothing (or only --global) is specified. + return ['global']; + } + + private run(mode: InstallMode): InstallResult[] { + const source = resolveSkillSource(); + logger.debug(`skill source: ${source}`); + + const results: InstallResult[] = []; + for (const scope of this.scopes) { + for (const tool of this.tools) { + const result = installOne(source, tool, scope, mode, this.force); + results.push(result); + this.logResult(result); + } + } + this.logSummary(mode, results); + return results; + } + + private logResult(result: InstallResult): void { + const label = `${result.tool} (${result.scope})`; + if (result.status === 'skipped') { + logger.info( + `- ${label}: skipped, already installed at ${result.target} (use update or --force)`, + ); + } else { + logger.info(`✔ ${label}: ${result.status} → ${result.target}`); + } + } + + private logSummary(mode: InstallMode, results: InstallResult[]): void { + const skipped = results.filter((r) => r.status === 'skipped').length; + const changed = results.length - skipped; + logger.info(`skill ${mode} done: ${changed} written, ${skipped} skipped`); + } +} diff --git a/src/subCommands/skill/installer.ts b/src/subCommands/skill/installer.ts new file mode 100644 index 00000000..32f15b82 --- /dev/null +++ b/src/subCommands/skill/installer.ts @@ -0,0 +1,87 @@ +import * as path from 'path'; +import * as os from 'os'; +import fs from 'fs-extra'; +import { SKILL_NAME, TOOL_DIRS, SKILLS_SUBDIR } from './constants'; + +export type InstallMode = 'install' | 'update'; +export type InstallStatus = 'installed' | 'updated' | 'skipped' | 'overwritten'; + +export interface InstallResult { + tool: string; + scope: 'global' | 'project'; + target: string; + status: InstallStatus; +} + +/** + * Candidate locations for the bundled skill source, in priority order: + * 1. `/skills/` — the published npm package (ncc bundle: __dirname is dist). + * 2. repo `.agents/skills/` — running from source (ts-node / jest, __dirname is src/subCommands/skill). + * 3. cwd `.agents/skills/` — last-resort fallback. + */ +export function skillSourceCandidates(): string[] { + return [ + path.join(__dirname, SKILLS_SUBDIR, SKILL_NAME), + path.join(__dirname, '..', '..', '..', '.agents', SKILLS_SUBDIR, SKILL_NAME), + path.join(process.cwd(), '.agents', SKILLS_SUBDIR, SKILL_NAME), + ]; +} + +/** Resolve the bundled skill source directory, or throw if none is found. */ +export function resolveSkillSource(): string { + for (const candidate of skillSourceCandidates()) { + if (fs.existsSync(path.join(candidate, 'SKILL.md'))) { + return candidate; + } + } + throw new Error( + `Skill source "${SKILL_NAME}" not found. Looked in:\n ${skillSourceCandidates().join('\n ')}`, + ); +} + +/** The install root for a scope: user home for global, cwd for project. */ +export function scopeRoot(scope: 'global' | 'project'): string { + return scope === 'global' ? os.homedir() : process.cwd(); +} + +/** Absolute target directory where the skill will be installed for a tool + scope. */ +export function targetDir(tool: string, scope: 'global' | 'project'): string { + const toolDir = TOOL_DIRS[tool]; + if (!toolDir) { + throw new Error(`Unsupported tool "${tool}". Supported: ${Object.keys(TOOL_DIRS).join(', ')}`); + } + return path.join(scopeRoot(scope), toolDir, SKILLS_SUBDIR, SKILL_NAME); +} + +/** + * Install (or update) the skill into a single tool + scope target. + * - install: skip if the target already exists, unless `force`. + * - update: always overwrite an existing target. + */ +export function installOne( + source: string, + tool: string, + scope: 'global' | 'project', + mode: InstallMode, + force: boolean, +): InstallResult { + const target = targetDir(tool, scope); + const exists = fs.existsSync(target); + + if (mode === 'install' && exists && !force) { + return { tool, scope, target, status: 'skipped' }; + } + + // Replace the target atomically-ish: remove then copy, so stale files never linger. + fs.removeSync(target); + fs.ensureDirSync(path.dirname(target)); + fs.copySync(source, target, { dereference: true }); + + let status: InstallStatus; + if (mode === 'update') { + status = 'updated'; + } else { + status = exists ? 'overwritten' : 'installed'; + } + return { tool, scope, target, status }; +}