Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
11. **系统工具** — `spark magic` 提供 DNS 缓存刷新、`node_modules`/`.venv` 清理、pip/npm/go 镜像源切换、Neovim/Ghostty 模板部署
12. **文档管理** — `spark docs init`/`spark docs site`(docmd 站点初始化)
13. **进程诊断** — `spark witr`(Why Is This Running),检查进程或端口为何在运行
14. **仓库管理** — `spark repo` 通过 registry 文件管理目录下的多个 GitHub 仓库(`scan`/`clone`/`list`),替代 submodule
14. **仓库管理** — `spark repo` 通过 registry 文件管理目录下的多个 GitHub 仓库(`scan`/`clone`/`list`),替代 submodule;`scan` 输出分类格式(version 2:`分类/子分类/others`),无分类目录的目录自动回退旧扁平格式
15. **Skill 管理** — `spark skill` 发现并同步 AI agent skills(`SKILL.md` 目录),支持 `list`/`sync`
16. **项目 Hub** — `spark hub scan` 扫描本地 Git 仓库并生成 innate-apps registry(`registry/apps.json`)注册信息

Expand Down Expand Up @@ -150,7 +150,7 @@ spark
│ ├── push-all (-p)
│ └── scan ([folder-path], -d, --db, --skip-api)
├── repo
│ ├── scan ([folder-name])
│ ├── scan ([folder-name], --json, --flatten)
│ ├── clone (-r, --repo, -f, --file)
│ └── list (-f, --file)
├── script
Expand Down Expand Up @@ -582,13 +582,42 @@ spark git scan . --db ~/.innate/feeds.db
父命令 `RepoCmd` 在 `cmd/repo/repo.go` 中定义,通过 registry 文件管理目录下的多个 GitHub 仓库(替代 submodule)。

```bash
spark repo scan [folder-name] # 扫描目录并写入 registry_<folder>.yaml
spark repo clone -f registry_<folder>.yaml # 克隆 registry 中全部仓库
spark repo scan [folder-name] # 扫描分类目录并写入 registry_<folder>.yaml
spark repo scan [folder-name] --json # 同时写出格式化 registry_<folder>.json
spark repo scan [folder-name] --flatten # (仅旧两层布局)拍平为 category/repo 再扫描
spark repo clone -f registry_<folder>.yaml # 克隆 registry 中全部仓库(按 path 还原)
spark repo clone -r <name> -f registry_<folder>.yaml # 仅克隆指定仓库
spark repo list -f registry_<folder>.yaml # 列出 registry 中的仓库
spark repo list -f registry_<folder>.yaml # 列出 registry 中的仓库(分类格式按分组输出)
```

registry 文件格式:
registry 文件格式(分类版,三分层目录扫描的默认输出;无一级分类目录时仍是旧扁平 `projects:` 格式):

```yaml
# Project registry
# Synced by spark repo scan

version: 2
categories:
- name: skills
subcategories:
- name: agent
projects:
- name: taste-skill
repo: https://github.com/Leonxlnx/taste-skill.git
path: skills/agent/taste-skill
- name: others
projects: []
- name: products
subcategories:
- name: others
projects: []
- name: plugins
subcategories:
- name: others
projects: []
```

旧扁平格式:

```yaml
# Project registry
Expand All @@ -603,7 +632,14 @@ projects:

#### `spark repo scan [folder-name]`

递归扫描目录,发现带 `origin` 远程的 Git 仓库,写入当前目录下的 `registry_<folder>.yaml`。扫描时跳过隐藏目录与 `node_modules`/`venv`/`.venv`/`__pycache__`/`dist`/`build`;SSH URL 归一化为 HTTPS 形式存储;重复扫描与已有 registry 合并(按 `path` 优先、其次按 `repo` URL 匹配,保留旧 `name`/`desc`,删除目录已不存在的旧条目,追加新发现仓库)。
递归扫描目录,发现带 `origin` 远程的 Git 仓库,写入当前目录下的 `registry_<folder>.yaml`。目录约定为**三层分类**:`<分类>/<子分类>/<项目>`——分类取自一级子目录(即使为空也入册),每个分类恒有 `others` 子分类,直接放在分类下的项目(无子分类)归入 `others`,项目内部更深的嵌套仓保全完整 path 但仍归入其前两段的 `分类/子分类`,直接放在扫描根目录的仓库打印 `WARNING` 且不入册(需先移入分类);没有任何一级分类目录时回退旧扁平格式。扫描时跳过隐藏目录与 `node_modules`/`venv`/`.venv`/`__pycache__`/`dist`/`build`;SSH URL 归一化为 HTTPS 形式存储;重复扫描与已有 registry 合并(按 `path` 优先、其次按 `repo` URL 匹配,保留旧 `name`/`desc`,删除目录已不存在的旧条目,追加新发现仓库)。`--flatten` 只服务于旧两层布局——**禁止**在三分层分类树上使用(会把项目移出子分类)。

| 选项 | 说明 |
|------|------|
| `--json` | 在 YAML 之外额外写出格式化(2 空格缩进)的 `registry_<folder>.json`,内容一致 |
| `--flatten` | 扫描前把超过两层(`category/repo`)的仓库上移到两层布局(破坏性操作,显式开启) |

`--flatten` 由 `internal/registry.Flatten` 实现:深层仓库移动到 `<category>/<仓库名>`,目标已存在或本次运行中被占用时跳过并打印 `Skipped ...` 告警(不覆盖任何文件),移动后清理空的中间目录;按深度从深到浅处理,避免父目录先移动导致子路径失效。

#### `spark repo clone`

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `spark repo scan` — categorized registry format (version 2): groups repositories as `<category>/<subcategory>/<project>` from the folder's own directory layout, always emits an `others` subcategory per category (projects directly under a category land there), warns and skips repositories that sit directly in the folder root, and falls back to the legacy flat `projects:` format when the folder has no category directories; `list` prints `# category / subcategory (n)` group headers for categorized files, `clone` restores each project to its recorded `path` shallowest-path-first
- `spark repo scan --json` — additionally writes a formatted (2-space indent) `registry_<folder>.json` with the same content as the YAML registry
- `spark repo scan --flatten` — moves repositories nested deeper than two levels (`category/repo` layout) up into the two-level layout before scanning; conflicts are skipped with a warning and never overwritten, empty intermediate directories are removed (fixes #26)
- `spark skill list [--json]` — Discover agent skills (`SKILL.md` directories) across project (`.mimocode/`, `.agents/`), global (`~/.claude/`, `~/.agents/`, `~/.mimocode/`), and builtin skill-pack locations, deduplicated by name with version-aware builtin preference (`internal/skill`)
- `spark skill sync <dir> [--clear]` — Copy all discovered skills into a target directory, optionally clearing it first
- `spark hub scan [path] [-o <file>]` — Scan git repositories with an `origin` remote, enrich via the GitHub API (description, language, visibility), and emit innate-apps registry format (`registry/apps.json`) (`internal/collection`)
Expand Down
14 changes: 13 additions & 1 deletion cmd/repo/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

"spark/internal/registry"
Expand Down Expand Up @@ -37,7 +38,7 @@ otherwise all repositories in the registry are cloned.`,
return err
}

projects := reg.Projects
projects := reg.AllProjects()
if cloneRepoName != "" {
p, err := registry.FindByName(reg, cloneRepoName)
if err != nil {
Expand All @@ -51,6 +52,17 @@ otherwise all repositories in the registry are cloned.`,
return nil
}

// Clone shallow paths first so a parent project (and any nested clone
// recorded under its path) is created in the right order.
sort.SliceStable(projects, func(i, j int) bool {
di := strings.Count(projects[i].Path, "/")
dj := strings.Count(projects[j].Path, "/")
if di != dj {
return di < dj
}
return projects[i].Path < projects[j].Path
})

root := folderFromRegistryFile(cloneFile)
cloned, skipped, failed := 0, 0, 0
for _, p := range projects {
Expand Down
15 changes: 15 additions & 0 deletions cmd/repo/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ var listCmd = &cobra.Command{
return err
}

if reg.IsCategorized() {
// Categorized (v2) registry: print one "# category / subcategory (n)"
// header per bucket, then the same name<TAB>repo<TAB>path rows as the
// legacy format (rows stay machine-readable, headers start with '#').
for _, c := range reg.Categories {
for _, s := range c.Subcategories {
fmt.Printf("# %s / %s (%d)\n", c.Name, s.Name, len(s.Projects))
for _, p := range s.Projects {
fmt.Printf("%s\t%s\t%s\n", p.Name, p.Repo, p.Path)
}
}
}
return nil
}

if len(reg.Projects) == 0 {
fmt.Println("No repositories in registry.")
return nil
Expand Down
5 changes: 4 additions & 1 deletion cmd/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ git submodules.

Commands:
- scan: scan a folder and write its repositories to registry_<folder>.yaml
(categorized format: <category>/<subcategory>/<project>, "others"
as the default subcategory; folders without categories stay flat)
- clone: clone repositories from a registry file into the folder directory
- list: list repositories declared in a registry file`,
(categorized registries restore each project to its recorded path)
- list: list repositories declared in a registry file (grouped when categorized)`,
}

func init() {
Expand Down
91 changes: 86 additions & 5 deletions cmd/repo/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,34 @@ import (
"github.com/spf13/cobra"
)

var (
scanJSON bool
scanFlatten bool
)

var scanCmd = &cobra.Command{
Use: "scan [folder-name]",
Short: "Scan a folder for git repositories and save to registry_<folder>.yaml",
Long: `Recursively scan a folder for git repositories that have an origin remote and
write them to a registry file named registry_<folder>.yaml in the current directory.

The folder is expected to hold the categorized layout:

<category>/<subcategory>/<project> e.g. skills/agent/taste-skill

The registry is written in the categorized format (version: 2): categories are the
folder's immediate subdirectories (so empty categories still appear), every category
always contains an "others" subcategory, and a project directly under a category
(<category>/<project>, i.e. without a subcategory) is recorded under "others".
Folders without category directories keep the legacy flat "projects" format.

With --json, an additional registry_<folder>.json file with the same content is
written. With --flatten, repositories nested deeper than two levels are moved up
into the LEGACY two-level <category>/<repo> layout first (no files are overwritten;
conflicts are reported and left in place) — do NOT pass --flatten on a categorized
<category>/<subcategory>/<project> tree, it would move projects out of their
subcategory.

The registry records each repository's name, remote URL and relative path. Re-scanning
merges results and preserves existing descriptions.`,
Args: cobra.MaximumNArgs(1),
Expand All @@ -29,6 +51,19 @@ merges results and preserves existing descriptions.`,
return fmt.Errorf("resolve folder: %w", err)
}

if scanFlatten {
result, err := registry.Flatten(absFolder)
if err != nil {
return err
}
for _, m := range result.Moves {
fmt.Printf("Flattened %s -> %s\n", m.From, m.To)
}
for _, c := range result.Conflicts {
fmt.Printf("Skipped %s -> %s: %s\n", c.From, c.To, c.Reason)
}
}

projects, err := registry.Scan(absFolder)
if err != nil {
return err
Expand All @@ -42,20 +77,66 @@ merges results and preserves existing descriptions.`,
return err
}

final := registry.Merge(existing.Projects, projects)
if err := registry.Write(registryFile, &registry.Registry{Projects: final}); err != nil {
final := registry.Merge(existing.AllProjects(), projects)

groups, skipped, err := registry.Categorize(absFolder, final)
if err != nil {
return err
}
// Categorized output only once at least one project actually has a
// category (or the categorized tree holds no projects at all). A folder
// whose projects all sit in the root keeps the legacy flat format so
// nothing silently disappears from an existing registry.
classified := len(final) - len(skipped)
useCategories := len(groups) > 0 && (len(final) == 0 || classified > 0)
reg := &registry.Registry{Projects: final}
if useCategories {
reg = &registry.Registry{Categories: groups}
}
if err := registry.Write(registryFile, reg); err != nil {
return err
}

fmt.Printf("Scanned %s: found %d repositories\n", absFolder, len(projects))
for _, p := range projects {
fmt.Printf(" %s -> %s\n", p.Path, p.Repo)
if reg.IsCategorized() {
for _, c := range reg.Categories {
for _, s := range c.Subcategories {
if len(s.Projects) == 0 {
fmt.Printf("%s / %s (0)\n", c.Name, s.Name)
continue
}
fmt.Printf("%s / %s\n", c.Name, s.Name)
for _, p := range s.Projects {
fmt.Printf(" %s -> %s\n", p.Path, p.Repo)
}
}
}
for _, p := range skipped {
fmt.Printf("WARNING: %s sits directly in the folder root and has no category — move it into <category>/<subcategory>/ (not written to the registry)\n", p.Path)
}
} else {
if len(groups) > 0 && len(final) > 0 {
fmt.Println("NOTE: no project has a category — wrote the legacy flat registry; move projects into <category>/<subcategory>/ to get the categorized format")
}
for _, p := range projects {
fmt.Printf(" %s -> %s\n", p.Path, p.Repo)
}
}
fmt.Printf("Wrote %d projects to %s\n", len(reg.AllProjects()), registryFile)

if scanJSON {
jsonFile := fmt.Sprintf("registry_%s.json", folderName)
if err := registry.WriteJSON(jsonFile, reg); err != nil {
return err
}
fmt.Printf("Wrote %d projects to %s\n", len(reg.AllProjects()), jsonFile)
}
fmt.Printf("Wrote %d projects to %s\n", len(final), registryFile)
return nil
},
}

func init() {
scanCmd.Flags().BoolVar(&scanJSON, "json", false, "additionally write registry_<folder>.json")
scanCmd.Flags().BoolVar(&scanFlatten, "flatten", false, "move repositories deeper than two levels up into the category/repo layout before scanning")
RepoCmd.AddCommand(scanCmd)
}
Loading
Loading