feat(skill): add HuLa skill linking script and project context documentation

This commit is contained in:
Dawn
2026-01-11 02:50:33 +08:00
parent d560912507
commit 0aaf60c0d2
16 changed files with 756 additions and 0 deletions

2
package.json vendored
View File

@@ -71,6 +71,8 @@
"test:ui": "vitest --ui --coverage.enabled=true",
"========= 测试覆盖率 =========": "",
"coverage": "vitest run --coverage",
"========= 链接HuLa skill =========": "",
"skill": "node scripts/link-skills.js",
"========= 单独提交 =========": "",
"gitcz": "git-cz"
},

314
scripts/link-skills.js vendored Normal file
View File

@@ -0,0 +1,314 @@
import { checkbox } from '@inquirer/prompts'
import { openSync, promises as fs } from 'fs'
import path from 'path'
import { ReadStream, WriteStream } from 'node:tty'
import { styleText } from 'node:util'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const repoRoot = path.resolve(__dirname, '..')
const skillsRoot = path.join(repoRoot, 'skills')
async function main() {
const shouldSkip = process.env.HULA_SKIP_SKILL_LINK || process.env.CI
const isLifecycle = Boolean(process.env.npm_lifecycle_event)
const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY)
const forcePrompt = process.env.HULA_FORCE_SKILL_LINK === '1'
if (shouldSkip) {
console.log('已跳过技能链接:检测到 HULA_SKIP_SKILL_LINK 或 CI。')
return
}
const homeDir = process.env.HOME || process.env.USERPROFILE
if (!homeDir) {
console.log('已跳过技能链接:未检测到 HOME 或 USERPROFILE。')
return
}
let skillDirs = []
try {
const entries = await fs.readdir(skillsRoot, { withFileTypes: true })
skillDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
} catch {
console.log(`已跳过技能链接:无法读取 ${skillsRoot}`)
return
}
if (skillDirs.length === 0) {
console.log(`已跳过技能链接:${skillsRoot} 下未找到技能目录。`)
return
}
const codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex')
const claudeHome = process.env.CLAUDE_HOME || path.join(homeDir, '.claude')
const targets = [
{
id: 'codex',
name: 'Codex',
dir: path.join(codexHome, 'skills')
},
{
id: 'claude',
name: 'Claude',
dir: path.join(claudeHome, 'skills')
}
]
const targetsWithStatus = []
for (const target of targets) {
const status = await getTargetLinkStatus(target.dir, skillsRoot, skillDirs)
targetsWithStatus.push({ ...target, status })
}
if (targetsWithStatus.every((target) => target.status.allLinked)) {
console.log('已跳过技能链接:目标目录已全部链接。')
return
}
if (isLifecycle && !forcePrompt) {
const pending = targetsWithStatus.filter((target) => !target.status.allLinked)
const pendingNames = pending.map((target) => target.name).join(', ')
console.log(`技能链接待处理:${pendingNames}`)
console.log('请运行 `node scripts/link-skills.js` 进行链接。')
return
}
if (!canPrompt && !forcePrompt) {
console.log('已跳过技能链接:当前为非交互终端。')
console.log('请在终端运行 `node scripts/link-skills.js` 进行链接。')
return
}
let promptContext = null
let cleanupPromptContext = () => {}
if (!canPrompt) {
const fallback = createConsolePromptContext()
if (!fallback) {
console.log('已跳过技能链接:无法打开控制台用于交互。')
return
}
promptContext = fallback.context
cleanupPromptContext = fallback.cleanup
}
try {
const useChineseHelp = isChineseLocale()
const allTargetsValue = '__all__'
const keyLabelMap = useChineseHelp
? new Map([
['space', '空格'],
['⏎', '回车']
])
: null
const actionLabelMap = useChineseHelp
? new Map([
['navigate', '移动'],
['select', '选择'],
['all', '全选'],
['invert', '反选'],
['submit', '提交']
])
: null
const selectedTargets = await checkbox(
{
message: '请选择要链接的目标:',
required: true,
theme: {
icon: {
checked: '✓',
unchecked: '○',
cursor: ''
},
style: {
keysHelpTip: (keys) =>
keys
.map(([key, action]) => {
const keyLabel = keyLabelMap?.get(key) ?? key
const actionLabel = actionLabelMap?.get(action) ?? action
return `${styleText('bold', keyLabel)} ${styleText('dim', actionLabel)}`
})
.join(styleText('dim', ' · '))
}
},
choices: [
{
name: '链接全部未链接目标',
value: allTargetsValue,
checked: false
},
...targetsWithStatus.map((target) => ({
name: `${target.name} (${target.dir})`,
value: target.id,
checked: false,
disabled: target.status.allLinked ? '已链接' : false
}))
]
},
promptContext ?? undefined
)
const shouldLinkAll = selectedTargets.includes(allTargetsValue)
const selectedIds = shouldLinkAll
? targetsWithStatus.filter((target) => !target.status.allLinked).map((target) => target.id)
: selectedTargets.filter((value) => value !== allTargetsValue)
if (selectedIds.length === 0) {
console.log('已跳过技能链接:未选择目标。')
return
}
for (const target of targetsWithStatus) {
if (!selectedIds.includes(target.id)) {
continue
}
await linkSkillsToTarget(target.dir, skillsRoot, skillDirs)
}
} finally {
cleanupPromptContext()
}
}
main().catch((error) => {
console.log('技能链接失败。')
if (error instanceof Error) {
console.log(error.message)
}
})
function isChineseLocale() {
const candidates = [
process.env.LC_ALL,
process.env.LC_MESSAGES,
process.env.LANG,
Intl.DateTimeFormat().resolvedOptions().locale
].filter(Boolean)
const locale = candidates.join(' ').toLowerCase()
return locale.includes('zh')
}
function createConsolePromptContext() {
const isWindows = process.platform === 'win32'
const candidates = isWindows
? [
{ input: '\\\\.\\CONIN$', output: '\\\\.\\CONOUT$' },
{ input: 'CONIN$', output: 'CONOUT$' }
]
: [{ input: '/dev/tty', output: '/dev/tty' }]
for (const candidate of candidates) {
try {
const inputFd = openSync(candidate.input, 'r')
const outputFd = openSync(candidate.output, 'w')
const input = new ReadStream(inputFd)
const output = new WriteStream(outputFd)
return {
context: { input, output },
cleanup: () => {
input.destroy()
output.destroy()
}
}
} catch {}
}
return null
}
async function getTargetLinkStatus(targetDir, sourceRoot, skillNames) {
let linkedCount = 0
for (const skillName of skillNames) {
const source = path.join(sourceRoot, skillName)
const dest = path.join(targetDir, skillName)
if (await isSkillLinked(source, dest)) {
linkedCount += 1
}
}
return {
allLinked: linkedCount === skillNames.length
}
}
async function isSkillLinked(source, dest) {
let destStat = null
try {
destStat = await fs.lstat(dest)
} catch (error) {
if (error.code !== 'ENOENT') {
console.log(`已跳过技能链接:无法检查 ${dest}`)
}
return false
}
if (!destStat.isSymbolicLink()) {
return false
}
try {
const [destResolved, sourceResolved] = await Promise.all([fs.realpath(dest), fs.realpath(source)])
return destResolved === sourceResolved
} catch {
return false
}
}
async function linkSkillsToTarget(targetDir, sourceRoot, skillNames) {
await fs.mkdir(targetDir, { recursive: true })
const sourceRootResolved = await fs.realpath(sourceRoot)
for (const skillName of skillNames) {
const source = path.join(sourceRootResolved, skillName)
const dest = path.join(targetDir, skillName)
await ensureLink(source, dest)
}
}
async function ensureLink(source, dest) {
const sourceResolved = await fs.realpath(source)
let destStat = null
try {
destStat = await fs.lstat(dest)
} catch (error) {
if (error.code !== 'ENOENT') {
console.log(`已跳过技能链接:无法检查 ${dest}`)
return
}
}
if (destStat) {
if (destStat.isSymbolicLink()) {
try {
const destResolved = await fs.realpath(dest)
if (destResolved === sourceResolved) {
console.log(`技能链接已存在:${dest}`)
return
}
} catch {
console.log(`已跳过技能链接:${dest} 处的链接无效。`)
return
}
}
console.log(`已跳过技能链接:${dest} 已存在。`)
return
}
try {
if (process.platform === 'win32') {
await fs.symlink(sourceResolved, dest, 'junction')
} else {
await fs.symlink(sourceResolved, dest)
}
console.log(`已创建技能链接:${dest} -> ${sourceResolved}`)
} catch {
console.log(`技能链接失败:${dest}`)
}
}

View File

@@ -0,0 +1,40 @@
---
name: hula-skill
description: "HuLa project skill for frontend (Vue 3 + Vite + UnoCSS + Naive UI/Vant), backend (Tauri v2 + Rust + SeaORM/SQLite), full-stack flows, and build/release work. Use when the user mentions hula or HuLa or requests changes in this repository; after triggering, ask which scope (frontend/backend/fullstack/build-release) to enable."
---
# HuLa Skill
## Overview
Enable consistent changes across the HuLa frontend, backend, full-stack flows, and build/release tasks with repo-specific conventions and resources.
## Activation Gate
Ask which scope to enable: frontend, backend, fullstack, or build-release.
Confirm platform (desktop or mobile), target area (view/component/store/command), and any constraints before editing.
## Workflow
1. Identify scope and platform.
2. Locate similar code paths and follow existing patterns.
3. Apply changes using repo conventions and available templates.
4. Update related layers (routes, stores, commands) when needed.
5. Propose or run checks/tests only when requested.
## Scope Routing
- Frontend: read `references/frontend.md` and `references/overview.md`; use `assets/templates/view-desktop.vue`, `assets/templates/view-mobile.vue`, `assets/templates/pinia-store.ts` as starters.
- Backend: read `references/backend.md` and `references/overview.md`; use `assets/templates/tauri-command.rs`.
- Fullstack: read `references/fullstack.md` plus frontend/backend references; use `assets/templates/tauri-command.ts`.
- Build/Release: read `references/build-release.md` and `references/checklists.md`.
## Scripts
Use `scripts/hula_summary.py` for quick repo context (views/stores counts and paths).
Use `scripts/hula_tauri_map.py` to list Tauri commands and frontend invoke usage.
## References
Use `references/overview.md` for stack, directories, aliases, and global conventions.
Use `references/checklists.md` for per-scope checklists.

View File

@@ -0,0 +1,13 @@
import { defineStore } from 'pinia'
import { StoresEnum } from '@/enums'
// Add the enum entry in src/enums/index.ts before using this store.
export const useExampleStore = defineStore(StoresEnum.EXAMPLE, () => {
const items = ref<string[]>([])
const addItem = (value: string) => {
items.value.push(value)
}
return { items, addItem }
})

View File

@@ -0,0 +1,4 @@
#[tauri::command]
pub async fn example_command(payload: String) -> Result<String, String> {
Ok(payload)
}

View File

@@ -0,0 +1,7 @@
import { TauriCommand } from '@/enums'
import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler'
// Add the enum entry in src/enums/index.ts before using this command.
export const exampleCommand = async (payload: string): Promise<string> => {
return await invokeWithErrorHandler<string>(TauriCommand.EXAMPLE_COMMAND, { payload })
}

View File

@@ -0,0 +1,15 @@
<template>
<section class="size-full p-16px">
<n-flex vertical :size="12">
<h1 class="text-18px font-600">{{ title }}</h1>
<div class="text-14px text-[--text-color]">
{{ description }}
</div>
</n-flex>
</section>
</template>
<script setup lang="ts">
const title = 'Title'
const description = 'Description'
</script>

View File

@@ -0,0 +1,13 @@
<template>
<section class="min-h-100vh p-16px">
<div class="text-18px font-600">{{ title }}</div>
<div class="text-14px text-[--text-color] mt-8px">
{{ description }}
</div>
</section>
</template>
<script setup lang="ts">
const title = 'Title'
const description = 'Description'
</script>

View File

@@ -0,0 +1,24 @@
# Backend Guide (Tauri + Rust)
## Command Locations
- Shared commands: `src-tauri/src/command/`
- Desktop-specific: `src-tauri/src/desktops/`
- Mobile-specific: `src-tauri/src/mobiles/`
## Add a New Command
1. Define a `#[tauri::command]` function in an existing module or a new module.
2. If you add a new module, export it from `src-tauri/src/command/mod.rs`.
3. Register the command in `tauri::generate_handler![...]` inside `src-tauri/src/lib.rs`.
4. Keep signatures async when IO or DB is involved.
## Database
- Use SeaORM entities in `src-tauri/entity/`.
- Add schema changes via migrations in `src-tauri/migration/`.
## Shared State
- Access shared state via `tauri::State<'_, AppData>`.
- Keep long operations async to avoid blocking the runtime.

View File

@@ -0,0 +1,17 @@
# Build and Release Guide
## Common Commands
- Install deps: `pnpm install`
- Desktop dev: `pnpm tauri:dev`
- Desktop build: `pnpm tauri:build`
- Lint/format check: `pnpm check`
- Auto-fix: `pnpm check:write`
- Vue formatting: `pnpm format:vue` or `pnpm format:all`
- Tests: `pnpm test:run`
- Commit helper: `pnpm commit`
## Notes
- Use the registry configured in `.npmrc`. Override locally only if needed.
- Avoid committing secrets; use `.env.local` for personal tokens.

View File

@@ -0,0 +1,27 @@
# Checklists
## Frontend Changes
- Confirm platform (desktop/mobile) and target window/view.
- Reuse existing view/component patterns from nearby files.
- Update routes in `src/router/index.ts` when adding a new view.
- Add i18n keys under `locales/` when new user-facing strings appear.
- Use UnoCSS utilities and shared tokens from `src/styles/scss/global/variable.scss`.
## Backend Changes
- Add `#[tauri::command]` and register it in `src-tauri/src/lib.rs`.
- Export new command modules in `src-tauri/src/command/mod.rs` if added.
- Update SeaORM entities and migrations when schema changes.
## Fullstack Changes
- Add a `TauriCommand` enum entry if the frontend uses a named constant.
- Add or update a frontend wrapper in `src/services/tauriCommand.ts`.
- Use `invokeWithErrorHandler` for consistent error handling where needed.
- Validate payload and response types end-to-end.
## Build/Release Work
- Use the existing `pnpm` scripts for checks and builds.
- Avoid touching `.rules` unless explicitly asked.

View File

@@ -0,0 +1,43 @@
# Frontend Guide
## Default Stack
- Vue 3 Composition API with `<script setup>`
- Vite 7 + TypeScript
- Pinia for global state
- UnoCSS for utility styling
- Naive UI on desktop, Vant on mobile
- vue-i18n for translations
## File Placement
- Desktop views: `src/views/`
- Mobile views: `src/mobile/views/`
- Shared components: `src/components/`
- Mobile components: `src/mobile/components/`
- Layouts: `src/layout/` and `src/mobile/layout/`
- Routes: `src/router/index.ts`
- Stores: `src/stores/`
- Services/hooks/utils: `src/services/`, `src/hooks/`, `src/utils/`
## Pinia Patterns
- Use setup-style stores: `defineStore(StoresEnum.X, () => { ... })`.
- Keep imperative logic inside store actions.
- Use `storeToRefs` when destructuring state in components.
- Enable persistence per-store with `persist: true` only when needed.
## Styling
- Prefer UnoCSS utilities for simple styling.
- Use `src/styles/scss/global/variable.scss` for shared tokens.
- Consume tokens via `bg-[--token]`, `text-[--token]`, `border-[--token]`.
## Routing and Views
- Add new routes in `src/router/index.ts`.
- Use `@/` for `src/` imports and `#/` for `src/mobile/`.
## i18n
- Add new strings under `locales/` and reference via `t(...)`.

View File

@@ -0,0 +1,16 @@
# Fullstack Flow Guide
## Add or Update a Tauri Command End-to-End
1. Implement a `#[tauri::command]` in Rust and register it in `src-tauri/src/lib.rs`.
2. Add or update the enum entry in `src/enums/index.ts` (`TauriCommand`) when a named constant is preferred.
3. Add a typed wrapper in `src/services/tauriCommand.ts` or call `invoke` directly in a hook/service.
4. Use `invokeWithErrorHandler` in `src/utils/TauriInvokeHandler.ts` when you need standardized error handling.
5. Update any related types in `src/services/types` or local module types.
## Typical Touch Points
- Rust: `src-tauri/src/command/`, `src-tauri/src/lib.rs`
- Frontend enums: `src/enums/index.ts`
- Frontend wrappers: `src/services/tauriCommand.ts`
- Invoke helpers: `src/utils/TauriInvokeHandler.ts`

View File

@@ -0,0 +1,45 @@
# HuLa Overview
## Stack
- Tauri v2 + Rust
- Vue 3 (Composition API) + Vite 7 + TypeScript
- Pinia + pinia-plugin-persistedstate + pinia-shared-state
- UnoCSS + Sass
- Naive UI (desktop), Vant (mobile)
- SeaORM + SQLite (SQLCipher)
- vue-i18n
## Repo Layout
- `src/` frontend source
- `views/`, `components/`, `layout/`, `services/`, `stores/`, `hooks/`, `router/`, `utils/`
- `mobile/` for mobile views/components/layout
- `src-tauri/` Rust backend
- `src/` application logic, commands, desktop/mobile modules
- `entity/` SeaORM entities
- `migration/` SeaORM migrations
- `tauri-plugin-hula/` local Tauri plugin
## Aliases
- `@/` -> `src/`
- `#/` -> `src/mobile/`
- `~/` -> repo root
## Conventions
- Use 2-space indent and LF line endings.
- Prefer `<script setup>` and the Composition API.
- Prefer UnoCSS utilities; use `src/styles/scss/global/variable.scss` for shared tokens.
- Use `storeToRefs` when destructuring Pinia state.
- Do not change `.rules` unless asked.
- Do not add secrets to tracked files; use `.env.local`.
## Common Files
- Router: `src/router/index.ts`
- Tauri command enum: `src/enums/index.ts` (`TauriCommand`)
- Frontend command wrappers: `src/services/tauriCommand.ts`
- Tauri invoke helpers: `src/utils/TauriInvokeHandler.ts`
- Theme tokens: `src/styles/scss/global/variable.scss`

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import argparse
import sys
from pathlib import Path
def find_repo_root(start: Path) -> Path | None:
for path in [start] + list(start.parents):
if (path / "package.json").exists() and (path / "src").exists():
return path
return None
def list_views(root: Path) -> list[Path]:
view_paths = []
desktop = root / "src" / "views"
mobile = root / "src" / "mobile" / "views"
if desktop.exists():
view_paths.extend(desktop.rglob("*.vue"))
if mobile.exists():
view_paths.extend(mobile.rglob("*.vue"))
return sorted(view_paths, key=lambda path: str(path))
def list_stores(root: Path) -> list[Path]:
stores_dir = root / "src" / "stores"
if not stores_dir.exists():
return []
return sorted(stores_dir.glob("*.ts"), key=lambda path: str(path))
def print_paths(paths: list[Path], root: Path) -> None:
for path in paths:
print(path.relative_to(root))
def main() -> int:
parser = argparse.ArgumentParser(description="Summarize HuLa frontend layout.")
parser.add_argument("--root", help="Repo root (defaults to auto-detect).")
parser.add_argument("--views", action="store_true", help="List view files.")
parser.add_argument("--stores", action="store_true", help="List store files.")
parser.add_argument("--summary", action="store_true", help="Print counts summary.")
args = parser.parse_args()
root = Path(args.root).resolve() if args.root else find_repo_root(Path(__file__).resolve())
if not root:
print("ERROR: Could not locate repo root (missing package.json).", file=sys.stderr)
return 1
if not (args.views or args.stores or args.summary):
args.summary = True
views = list_views(root)
stores = list_stores(root)
if args.summary:
print(f"Repo: {root}")
print(f"Views: {len(views)}")
print(f"Stores: {len(stores)}")
if args.views:
print("View files:")
print_paths(views, root)
if args.stores:
print("Store files:")
print_paths(stores, root)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import argparse
import re
import sys
from pathlib import Path
FN_RE = re.compile(r"\bfn\s+([A-Za-z0-9_]+)\b")
INVOKE_STRING_RE = re.compile(r"\b(?:invoke|invokeWithErrorHandler)\s*\(\s*(['\"])([^'\"]+)\1")
INVOKE_ENUM_RE = re.compile(r"\b(?:invoke|invokeWithErrorHandler)\s*\(\s*TauriCommand\.([A-Za-z0-9_]+)")
def find_repo_root(start: Path) -> Path | None:
for path in [start] + list(start.parents):
if (path / "package.json").exists() and (path / "src").exists():
return path
return None
def scan_rust_commands(root: Path) -> list[tuple[str, Path, int]]:
src_dir = root / "src-tauri" / "src"
if not src_dir.exists():
return []
results: list[tuple[str, Path, int]] = []
for rust_file in src_dir.rglob("*.rs"):
lines = rust_file.read_text(encoding="utf-8", errors="ignore").splitlines()
idx = 0
while idx < len(lines):
if "#[tauri::command]" in lines[idx]:
seek = idx + 1
while seek < len(lines):
match = FN_RE.search(lines[seek])
if match:
results.append((match.group(1), rust_file, seek + 1))
break
seek += 1
idx = seek
idx += 1
return results
def scan_invoke_usage(root: Path) -> tuple[list[tuple[str, Path, int]], list[tuple[str, Path, int]]]:
src_dir = root / "src"
if not src_dir.exists():
return ([], [])
string_hits: list[tuple[str, Path, int]] = []
enum_hits: list[tuple[str, Path, int]] = []
for src_file in src_dir.rglob("*"):
if not src_file.is_file():
continue
if src_file.suffix not in {".ts", ".vue"}:
continue
lines = src_file.read_text(encoding="utf-8", errors="ignore").splitlines()
for line_idx, line in enumerate(lines, start=1):
for match in INVOKE_STRING_RE.finditer(line):
string_hits.append((match.group(2), src_file, line_idx))
for match in INVOKE_ENUM_RE.finditer(line):
enum_hits.append((match.group(1), src_file, line_idx))
return (string_hits, enum_hits)
def print_hits(title: str, hits: list[tuple[str, Path, int]], root: Path, detail: bool) -> None:
unique = sorted({name for name, _, _ in hits})
print(f"{title}: {len(unique)}")
if detail:
for name, path, line in hits:
print(f"{name}\t{path.relative_to(root)}:{line}")
else:
for name in unique:
print(name)
def main() -> int:
parser = argparse.ArgumentParser(description="List Tauri commands and frontend invoke usage.")
parser.add_argument("--root", help="Repo root (defaults to auto-detect).")
parser.add_argument("--rust", action="store_true", help="List Rust #[tauri::command] functions.")
parser.add_argument("--invoke", action="store_true", help="List frontend invoke usage.")
parser.add_argument("--detail", action="store_true", help="Include file and line details.")
args = parser.parse_args()
root = Path(args.root).resolve() if args.root else find_repo_root(Path(__file__).resolve())
if not root:
print("ERROR: Could not locate repo root (missing package.json).", file=sys.stderr)
return 1
if not (args.rust or args.invoke):
args.rust = True
args.invoke = True
if args.rust:
rust_hits = scan_rust_commands(root)
print_hits("Rust commands", rust_hits, root, args.detail)
if args.invoke:
string_hits, enum_hits = scan_invoke_usage(root)
print_hits("Invoke string commands", string_hits, root, args.detail)
print_hits("Invoke enum commands", enum_hits, root, args.detail)
return 0
if __name__ == "__main__":
raise SystemExit(main())