loopat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Vault catalog & resolution.
3
+ *
4
+ * A vault is a named bundle of credentials owned by one user. Each loop
5
+ * selects one vault at spawn time. The vault is NOT mounted into the sandbox
6
+ * as a directory; instead, two filesystem conventions drive automatic delivery:
7
+ *
8
+ * vaults/<v>/envs/<NAME> → injected as env var $NAME
9
+ * vaults/<v>/mounts/home/<rel>/... → bound at $HOME/<rel>/...
10
+ *
11
+ * AI sees a configured machine, not a "vault" directory.
12
+ *
13
+ * Filesystem:
14
+ * personal/<user>/.loopat/vaults/<name>/...
15
+ *
16
+ * Symlinks within a vault are allowed and follow Linux semantics, BUT
17
+ * `walkVaultFiles` rejects any file whose realpath escapes
18
+ * `personal/<user>/` — symlinks pointing at host paths outside the user's
19
+ * own tree are a privilege-escalation vector and never bind into the sandbox.
20
+ */
21
+ import { existsSync, readdirSync, statSync } from "node:fs"
22
+ import { readFile, realpath, readdir, stat } from "node:fs/promises"
23
+ import { join, relative, sep } from "node:path"
24
+ import {
25
+ personalDir,
26
+ personalVaultDir,
27
+ personalVaultsDir,
28
+ personalVaultEnvsDir,
29
+ personalVaultMountsHomeDir,
30
+ } from "./paths"
31
+
32
+ export const DEFAULT_VAULT = "default"
33
+
34
+ const VAULT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
35
+ export function isValidVaultName(name: string): boolean {
36
+ return VAULT_NAME_RE.test(name)
37
+ }
38
+
39
+ /** List vault names: subdirectories under `personal/<user>/.loopat/vaults/`. */
40
+ export function listVaults(user: string): string[] {
41
+ const vaultsDir = personalVaultsDir(user)
42
+ if (!existsSync(vaultsDir)) return []
43
+ try {
44
+ return readdirSync(vaultsDir)
45
+ .filter((name) => isValidVaultName(name))
46
+ .filter((name) => {
47
+ try {
48
+ return statSync(join(vaultsDir, name)).isDirectory()
49
+ } catch {
50
+ return false
51
+ }
52
+ })
53
+ .sort()
54
+ } catch {
55
+ return []
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Return the host-side root directory for the named vault, or null if it
61
+ * doesn't exist on disk.
62
+ */
63
+ export function resolveVaultRoot(user: string, vault: string): string | null {
64
+ if (!isValidVaultName(vault)) return null
65
+ const path = personalVaultDir(user, vault)
66
+ return existsSync(path) ? path : null
67
+ }
68
+
69
+ /**
70
+ * Walk a vault root and yield (relPath, realpath) pairs for every regular
71
+ * file (following symlinks). Rejects symlinks whose realpath escapes
72
+ * `personal/<user>/` — these are dropped (caller can log) instead of
73
+ * silently exposing a host path.
74
+ */
75
+ export async function* walkVaultFiles(
76
+ user: string,
77
+ vaultRoot: string,
78
+ ): AsyncGenerator<{ rel: string; realpath: string }> {
79
+ const userRoot = personalDir(user)
80
+ const userRootReal = await realpath(userRoot).catch(() => userRoot)
81
+
82
+ async function* visit(dir: string, prefix: string): AsyncGenerator<{ rel: string; realpath: string }> {
83
+ let entries: string[]
84
+ try {
85
+ entries = await readdir(dir)
86
+ } catch {
87
+ return
88
+ }
89
+ for (const name of entries) {
90
+ const abs = join(dir, name)
91
+ const rel = prefix ? `${prefix}/${name}` : name
92
+ let st
93
+ try {
94
+ st = await stat(abs) // follows symlinks
95
+ } catch {
96
+ continue
97
+ }
98
+ if (st.isDirectory()) {
99
+ yield* visit(abs, rel)
100
+ continue
101
+ }
102
+ if (!st.isFile()) continue
103
+ let resolved: string
104
+ try {
105
+ resolved = await realpath(abs)
106
+ } catch {
107
+ continue
108
+ }
109
+ const insideUser = relative(userRootReal, resolved)
110
+ if (insideUser.startsWith("..") || insideUser === "" || insideUser.startsWith(`/${sep}`)) {
111
+ // realpath escaped personal/<user>/ — refuse to bind
112
+ console.warn(`[loopat] vault symlink rejected (escapes user root): ${abs} → ${resolved}`)
113
+ continue
114
+ }
115
+ yield { rel, realpath: resolved }
116
+ }
117
+ }
118
+
119
+ yield* visit(vaultRoot, "")
120
+ }
121
+
122
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
123
+
124
+ /**
125
+ * Load every file in `vaults/<v>/envs/` as an env-var map. Filename is the
126
+ * env var name; content is the value with one trailing newline stripped.
127
+ *
128
+ * Subdirectories under `envs/` are ignored. Files with non-env-var names
129
+ * (e.g. containing dashes or dots) are skipped — they're almost always
130
+ * accidental dotfiles or backup swap files, not real env entries.
131
+ *
132
+ * Missing vault or missing envs/ → empty map.
133
+ */
134
+ export async function loadVaultEnvs(user: string, vault: string): Promise<Record<string, string>> {
135
+ const dir = personalVaultEnvsDir(user, vault)
136
+ if (!existsSync(dir)) return {}
137
+ let names: string[]
138
+ try {
139
+ names = await readdir(dir)
140
+ } catch {
141
+ return {}
142
+ }
143
+ const out: Record<string, string> = {}
144
+ for (const name of names) {
145
+ if (!ENV_NAME_RE.test(name)) continue
146
+ let st
147
+ try {
148
+ st = await stat(join(dir, name))
149
+ } catch {
150
+ continue
151
+ }
152
+ if (!st.isFile()) continue
153
+ try {
154
+ const raw = await readFile(join(dir, name), "utf8")
155
+ out[name] = raw.replace(/[\r\n]+$/, "")
156
+ } catch {}
157
+ }
158
+ return out
159
+ }
160
+
161
+ /** A single sandbox bind derived from `vaults/<v>/mounts/home/<top>`. */
162
+ export type VaultHomeMount = {
163
+ /** Absolute host path (under the vault dir). */
164
+ src: string
165
+ /** Path relative to sandbox $HOME (e.g. ".ssh", ".config/gh"). */
166
+ rel: string
167
+ }
168
+
169
+ /**
170
+ * Enumerate top-level entries under `vaults/<v>/mounts/home/`. Each one
171
+ * produces a single bind: `<vault>/mounts/home/<name>` → `$HOME/<name>`.
172
+ *
173
+ * Top-level only by design: binding the whole `.ssh/` directory means the
174
+ * sandbox sees vault-owned `.ssh/`, no other writes allowed. Binding individual
175
+ * deeper files would require enumerating and re-running on every spawn — and
176
+ * users almost always want the whole directory owned by the source.
177
+ */
178
+ export function listVaultHomeMounts(user: string, vault: string): VaultHomeMount[] {
179
+ const dir = personalVaultMountsHomeDir(user, vault)
180
+ if (!existsSync(dir)) return []
181
+ try {
182
+ return readdirSync(dir).filter((n) => n && !n.startsWith(".#")).map((name) => ({
183
+ src: join(dir, name),
184
+ rel: name,
185
+ }))
186
+ } catch {
187
+ return []
188
+ }
189
+ }
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Workspace-level file APIs for Context tab vaults (knowledge / notes /
3
+ * personal / repos). Auto-commits on write per user's design:
4
+ * "每次修改自动 commit, log 记录动作"。
5
+ */
6
+ import { readdir, readFile, writeFile, stat, lstat, mkdir, rm, unlink, symlink } from "node:fs/promises"
7
+ // Re-using readFile for parsing focus/inbox markdown.
8
+ import { existsSync } from "node:fs"
9
+ import { execFile } from "node:child_process"
10
+ import { promisify } from "node:util"
11
+ import { homedir } from "node:os"
12
+ import { join, normalize, relative, resolve as resolvePath, sep, dirname } from "node:path"
13
+ import {
14
+ workspaceKnowledgeDir,
15
+ workspaceReposDir,
16
+ personalDir,
17
+ uiNotesDir,
18
+ } from "./paths"
19
+
20
+ const execFileP = promisify(execFile)
21
+
22
+ export type VaultId = "knowledge" | "notes" | "personal" | "repos"
23
+
24
+ export type VaultEntry = {
25
+ name: string
26
+ path: string
27
+ type: "file" | "dir"
28
+ size?: number
29
+ }
30
+
31
+ export function vaultRoot(vault: VaultId, user: string): string {
32
+ switch (vault) {
33
+ case "knowledge":
34
+ return workspaceKnowledgeDir()
35
+ case "notes":
36
+ // notes is edited via a per-user UI-loop worktree (opened from origin/main);
37
+ // the endpoint ensures the worktree exists before this resolves.
38
+ return uiNotesDir(user)
39
+ case "personal":
40
+ return personalDir(user)
41
+ case "repos":
42
+ return workspaceReposDir()
43
+ }
44
+ }
45
+
46
+ function safeJoin(rootAbs: string, rel: string): string | null {
47
+ const candidate = normalize(join(rootAbs, rel))
48
+ const insideRel = relative(rootAbs, candidate)
49
+ if (insideRel.startsWith("..") || insideRel.startsWith("/" + sep)) return null
50
+ return candidate
51
+ }
52
+
53
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".bun"])
54
+
55
+ export async function vaultList(vault: VaultId, relPath: string, user: string): Promise<VaultEntry[]> {
56
+ const root = vaultRoot(vault, user)
57
+ const abs = safeJoin(root, relPath)
58
+ if (!abs) return []
59
+ let names: string[] = []
60
+ try {
61
+ names = await readdir(abs)
62
+ } catch {
63
+ return []
64
+ }
65
+ const out: VaultEntry[] = []
66
+ for (const name of names) {
67
+ if (SKIP_DIRS.has(name)) continue
68
+ if (name === ".git" || name === ".DS_Store") continue
69
+ const childRel = relPath ? `${relPath}/${name}` : name
70
+ let isDir = false
71
+ let size: number | undefined
72
+ try {
73
+ const s = await stat(join(abs, name))
74
+ isDir = s.isDirectory()
75
+ if (!isDir) size = s.size
76
+ } catch {
77
+ continue
78
+ }
79
+ out.push({ name, path: childRel, type: isDir ? "dir" : "file", size })
80
+ }
81
+ const isLoopatRoot = (e: VaultEntry) => vault === "personal" && e.type === "dir" && e.name === ".loopat" && relPath === ""
82
+ out.sort((a, b) => {
83
+ // .loopat/ pinned to the very bottom in personal vault root (platform-managed namespace)
84
+ if (isLoopatRoot(a) !== isLoopatRoot(b)) return isLoopatRoot(a) ? 1 : -1
85
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1
86
+ return a.name.localeCompare(b.name)
87
+ })
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Recursive flat list of files in a vault. Used for sidebar search.
93
+ */
94
+ export async function vaultFlatList(vault: VaultId, user: string): Promise<VaultEntry[]> {
95
+ const root = vaultRoot(vault, user)
96
+ const out: VaultEntry[] = []
97
+ const walk = async (abs: string, rel: string): Promise<void> => {
98
+ let names: string[] = []
99
+ try {
100
+ names = await readdir(abs)
101
+ } catch {
102
+ return
103
+ }
104
+ for (const name of names) {
105
+ if (SKIP_DIRS.has(name) || name === ".git" || name === ".DS_Store") continue
106
+ const childAbs = join(abs, name)
107
+ const childRel = rel ? `${rel}/${name}` : name
108
+ let s
109
+ try {
110
+ s = await stat(childAbs)
111
+ } catch {
112
+ continue
113
+ }
114
+ if (s.isDirectory()) {
115
+ await walk(childAbs, childRel)
116
+ } else {
117
+ out.push({ name, path: childRel, type: "file", size: s.size })
118
+ }
119
+ }
120
+ }
121
+ await walk(root, "")
122
+ return out
123
+ }
124
+
125
+ const MAX_BYTES = 1024 * 1024
126
+
127
+ /**
128
+ * Anything under `personal/<user>/.loopat/vaults/<vault>/...` is a secret
129
+ * value. The worktree holds plaintext (so the sandbox can use it) but the API
130
+ * surface MUST NEVER hand it back to the browser — editing means overwriting
131
+ * with a new value the user types, never decrypt-and-view.
132
+ */
133
+ function isSecretPath(vault: VaultId, relPath: string): boolean {
134
+ if (vault !== "personal") return false
135
+ if (!relPath.startsWith(".loopat/vaults/")) return false
136
+ // Need at least one path segment under the vault name to be a real file
137
+ // (`.loopat/vaults/prod` is the vault dir itself, not a secret).
138
+ const rest = relPath.slice(".loopat/vaults/".length)
139
+ return rest.includes("/")
140
+ }
141
+
142
+ export async function vaultRead(
143
+ vault: VaultId,
144
+ relPath: string,
145
+ user: string,
146
+ ): Promise<{ content: string; size: number; truncated: boolean; secret?: boolean } | null> {
147
+ const root = vaultRoot(vault, user)
148
+ const abs = safeJoin(root, relPath)
149
+ if (!abs) return null
150
+ try {
151
+ const s = await stat(abs)
152
+ if (!s.isFile()) return null
153
+ // Secrets: never return the plaintext, even to the authenticated user.
154
+ // Edit means "overwrite", never "decrypt and view".
155
+ if (isSecretPath(vault, relPath)) {
156
+ return { content: "", size: s.size, truncated: false, secret: true }
157
+ }
158
+ const truncated = s.size > MAX_BYTES
159
+ const buf = await readFile(abs)
160
+ const slice = truncated ? buf.subarray(0, MAX_BYTES) : buf
161
+ return { content: slice.toString("utf8"), size: s.size, truncated }
162
+ } catch {
163
+ return null
164
+ }
165
+ }
166
+
167
+ export async function vaultWrite(
168
+ vault: VaultId,
169
+ relPath: string,
170
+ content: string,
171
+ user: string,
172
+ ): Promise<{ ok: boolean; commit?: string; error?: string }> {
173
+ const root = vaultRoot(vault, user)
174
+ const abs = safeJoin(root, relPath)
175
+ if (!abs) return { ok: false, error: "path escapes root" }
176
+ try {
177
+ await mkdir(dirname(abs), { recursive: true })
178
+ await writeFile(abs, content)
179
+ } catch (e: any) {
180
+ return { ok: false, error: e?.message ?? "write failed" }
181
+ }
182
+ // auto-commit if root is a git repo
183
+ if (existsSync(join(root, ".git"))) {
184
+ try {
185
+ const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z")
186
+ const env = { ...process.env, GIT_AUTHOR_NAME: "loopat", GIT_AUTHOR_EMAIL: "auto@loopat.local", GIT_COMMITTER_NAME: "loopat", GIT_COMMITTER_EMAIL: "auto@loopat.local" }
187
+ await execFileP("git", ["-C", root, "add", "--", relPath], { env })
188
+ const { stdout } = await execFileP(
189
+ "git",
190
+ ["-C", root, "commit", "-m", `${relPath}: ${ts}`, "--allow-empty"],
191
+ { env },
192
+ )
193
+ const m = stdout.match(/\b([0-9a-f]{7,})\b/)
194
+ return { ok: true, commit: m?.[1] }
195
+ } catch (e: any) {
196
+ // file written but commit failed (e.g., no changes); still success
197
+ return { ok: true, error: e?.stderr ?? e?.message }
198
+ }
199
+ }
200
+ return { ok: true }
201
+ }
202
+
203
+ export async function vaultCreateFile(vault: VaultId, relPath: string, user: string): Promise<{ ok: boolean; error?: string }> {
204
+ const root = vaultRoot(vault, user)
205
+ const abs = safeJoin(root, relPath)
206
+ if (!abs) return { ok: false, error: "path escapes root" }
207
+ if (existsSync(abs)) return { ok: false, error: "exists" }
208
+ try {
209
+ await mkdir(dirname(abs), { recursive: true })
210
+ await writeFile(abs, "")
211
+ } catch (e: any) {
212
+ return { ok: false, error: e?.message }
213
+ }
214
+ return { ok: true }
215
+ }
216
+
217
+ export async function vaultCreateFolder(vault: VaultId, relPath: string, user: string): Promise<{ ok: boolean; error?: string }> {
218
+ const root = vaultRoot(vault, user)
219
+ const abs = safeJoin(root, relPath)
220
+ if (!abs) return { ok: false, error: "path escapes root" }
221
+ if (existsSync(abs)) return { ok: false, error: "exists" }
222
+ try {
223
+ await mkdir(abs, { recursive: true })
224
+ } catch (e: any) {
225
+ return { ok: false, error: e?.message }
226
+ }
227
+ return { ok: true }
228
+ }
229
+
230
+ export async function vaultDelete(vault: VaultId, relPath: string, user: string): Promise<{ ok: boolean; error?: string }> {
231
+ const root = vaultRoot(vault, user)
232
+ const abs = safeJoin(root, relPath)
233
+ if (!abs) return { ok: false, error: "path escapes root" }
234
+ try {
235
+ const s = await stat(abs)
236
+ if (s.isDirectory()) {
237
+ await rm(abs, { recursive: true, force: true })
238
+ } else {
239
+ await unlink(abs)
240
+ }
241
+ } catch (e: any) {
242
+ return { ok: false, error: e?.message ?? "delete failed" }
243
+ }
244
+ return { ok: true }
245
+ }
246
+
247
+ export type RepoEntry = {
248
+ name: string
249
+ path: string
250
+ remote?: string
251
+ }
252
+
253
+ export type Backlink = {
254
+ path: string // file path that links to the target
255
+ preview: string // first line of context around the link
256
+ }
257
+
258
+ /**
259
+ * Scan all .md files in the vault for `[[<basename of path>]]` references
260
+ * and return matching files with a short preview.
261
+ */
262
+ export async function vaultBacklinks(vault: VaultId, targetPath: string, user: string): Promise<Backlink[]> {
263
+ const root = vaultRoot(vault, user)
264
+ // basename without .md extension is the wikilink target
265
+ const baseName = targetPath.split("/").pop()?.replace(/\.md$/, "") ?? targetPath
266
+ const aliases = new Set<string>([baseName, targetPath, targetPath.replace(/\.md$/, "")])
267
+ const out: Backlink[] = []
268
+ const walk = async (dir: string): Promise<void> => {
269
+ let names: string[] = []
270
+ try {
271
+ names = await readdir(dir)
272
+ } catch {
273
+ return
274
+ }
275
+ for (const name of names) {
276
+ if (SKIP_DIRS.has(name) || name === ".git") continue
277
+ const p = join(dir, name)
278
+ let s
279
+ try {
280
+ s = await stat(p)
281
+ } catch {
282
+ continue
283
+ }
284
+ if (s.isDirectory()) {
285
+ await walk(p)
286
+ continue
287
+ }
288
+ if (!name.endsWith(".md")) continue
289
+ const rel = relative(root, p)
290
+ if (rel === targetPath) continue
291
+ let body = ""
292
+ try {
293
+ body = await readFile(p, "utf8")
294
+ } catch {
295
+ continue
296
+ }
297
+ // find any [[X]] where X matches one of aliases
298
+ const re = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g
299
+ let m: RegExpExecArray | null
300
+ while ((m = re.exec(body)) !== null) {
301
+ const target = m[1].trim()
302
+ if (aliases.has(target)) {
303
+ // grab the line
304
+ const lineStart = body.lastIndexOf("\n", m.index) + 1
305
+ const lineEnd = body.indexOf("\n", m.index)
306
+ const line = body.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim()
307
+ out.push({ path: rel, preview: line.slice(0, 200) })
308
+ break
309
+ }
310
+ }
311
+ }
312
+ }
313
+ await walk(root)
314
+ return out
315
+ }
316
+
317
+ const TOPIC_RE = /(?<![\w])#([A-Za-z0-9][\w-]*)/g
318
+ function extractTopics(text: string): string[] {
319
+ const out = new Set<string>()
320
+ let m: RegExpExecArray | null
321
+ while ((m = TOPIC_RE.exec(text)) !== null) {
322
+ out.add(m[1].toLowerCase())
323
+ }
324
+ return [...out]
325
+ }
326
+
327
+ /** Aggregate all topics across loop titles. */
328
+ export type TopicAggregate = {
329
+ name: string
330
+ loops: { id: string; title: string }[]
331
+ }
332
+
333
+ export async function listTopics(loopTitles: { id: string; title: string }[]): Promise<TopicAggregate[]> {
334
+ const map = new Map<string, TopicAggregate>()
335
+ for (const { id, title } of loopTitles) {
336
+ const topics = extractTopics(title)
337
+ for (const t of topics) {
338
+ let entry = map.get(t)
339
+ if (!entry) {
340
+ entry = { name: t, loops: [] }
341
+ map.set(t, entry)
342
+ }
343
+ entry.loops.push({ id, title })
344
+ }
345
+ }
346
+ return [...map.values()].sort((a, b) => {
347
+ const wb = b.loops.length
348
+ const wa = a.loops.length
349
+ if (wa !== wb) return wb - wa
350
+ return a.name.localeCompare(b.name)
351
+ })
352
+ }
353
+
354
+ export type RepoDetail = RepoEntry & {
355
+ branch?: string
356
+ status: "online" | "offline"
357
+ readme?: string
358
+ }
359
+
360
+ export async function readRepoDetail(name: string): Promise<RepoDetail | null> {
361
+ const path = join(workspaceReposDir(), name)
362
+ try {
363
+ const s = await stat(path)
364
+ if (!s.isDirectory()) return null
365
+ } catch {
366
+ return null
367
+ }
368
+ let remote: string | undefined
369
+ let branch: string | undefined
370
+ let online: "online" | "offline" = "online"
371
+ try {
372
+ const { stdout } = await execFileP("git", ["-C", path, "remote", "get-url", "origin"])
373
+ remote = stdout.trim()
374
+ } catch {
375
+ online = "offline"
376
+ }
377
+ try {
378
+ const { stdout } = await execFileP("git", ["-C", path, "symbolic-ref", "--short", "HEAD"])
379
+ branch = stdout.trim()
380
+ } catch {}
381
+ let readme: string | undefined
382
+ for (const candidate of ["README.md", "readme.md", "README", "Readme.md"]) {
383
+ try {
384
+ const buf = await readFile(join(path, candidate), "utf8")
385
+ readme = buf
386
+ break
387
+ } catch {}
388
+ }
389
+ return { name, path, remote, branch, status: online, readme }
390
+ }
391
+
392
+ const REPO_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
393
+
394
+ function isRepoUrl(source: string): boolean {
395
+ return /:\/\//.test(source) || /^git@/.test(source)
396
+ }
397
+
398
+ function expandHome(p: string): string {
399
+ if (p === "~") return homedir()
400
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2))
401
+ return p
402
+ }
403
+
404
+ export function deriveRepoName(source: string): string {
405
+ let s = source.trim().replace(/[?#].*$/, "")
406
+ s = s.replace(/\/+$/, "").replace(/\.git$/i, "")
407
+ const m = s.match(/[/:]([^/:]+)$/)
408
+ return (m ? m[1] : s).trim()
409
+ }
410
+
411
+ /**
412
+ * Register a repo under workspaceReposDir(). Source can be either:
413
+ * - a git URL (http/https/ssh/git@) → `git clone` into the target
414
+ * - a local filesystem path → symlink into the target
415
+ * Symlinks are preferred for local working trees so edits in the source
416
+ * tree show up in loops without re-cloning.
417
+ */
418
+ export async function addRepo(opts: { name: string; source: string }): Promise<{ ok: boolean; name?: string; kind?: "clone" | "symlink"; error?: string }> {
419
+ const source = (opts.source || "").trim()
420
+ if (!source) return { ok: false, error: "source required" }
421
+ const name = (opts.name || "").trim()
422
+ if (!REPO_NAME_RE.test(name)) {
423
+ return { ok: false, error: "invalid name (letters/digits/_.-, max 64, must start with alnum)" }
424
+ }
425
+ const root = workspaceReposDir()
426
+ const target = join(root, name)
427
+ try {
428
+ await lstat(target)
429
+ return { ok: false, error: "already exists" }
430
+ } catch {}
431
+ await mkdir(root, { recursive: true })
432
+ if (isRepoUrl(source)) {
433
+ try {
434
+ await execFileP("git", ["clone", source, target], { timeout: 300_000 })
435
+ return { ok: true, name, kind: "clone" }
436
+ } catch (e: any) {
437
+ const msg = (e?.stderr || e?.stdout || e?.message || "clone failed").toString().trim()
438
+ return { ok: false, error: msg }
439
+ }
440
+ }
441
+ const abs = resolvePath(expandHome(source))
442
+ try {
443
+ const s = await stat(abs)
444
+ if (!s.isDirectory()) return { ok: false, error: "source path is not a directory" }
445
+ } catch {
446
+ return { ok: false, error: "source path does not exist" }
447
+ }
448
+ try {
449
+ await symlink(abs, target)
450
+ return { ok: true, name, kind: "symlink" }
451
+ } catch (e: any) {
452
+ return { ok: false, error: e?.message ?? "symlink failed" }
453
+ }
454
+ }
455
+
456
+ export async function pullRepo(name: string): Promise<{ ok: boolean; output?: string; error?: string }> {
457
+ const path = join(workspaceReposDir(), name)
458
+ try {
459
+ const s = await stat(path)
460
+ if (!s.isDirectory()) return { ok: false, error: "not found" }
461
+ } catch {
462
+ return { ok: false, error: "not found" }
463
+ }
464
+ if (!existsSync(join(path, ".git"))) return { ok: false, error: "not a git repo" }
465
+ try {
466
+ const { stdout, stderr } = await execFileP("git", ["-C", path, "pull", "--ff-only"], { timeout: 60_000 })
467
+ return { ok: true, output: `${stdout}${stderr}`.trim() }
468
+ } catch (e: any) {
469
+ const msg = (e?.stderr || e?.stdout || e?.message || "pull failed").toString().trim()
470
+ return { ok: false, error: msg }
471
+ }
472
+ }
473
+
474
+ export async function listRepos(): Promise<RepoEntry[]> {
475
+ const root = workspaceReposDir()
476
+ let names: string[] = []
477
+ try {
478
+ names = await readdir(root)
479
+ } catch {
480
+ return []
481
+ }
482
+ const out: RepoEntry[] = []
483
+ for (const name of names) {
484
+ const p = join(root, name)
485
+ let target = p
486
+ try {
487
+ const s = await stat(p)
488
+ if (!s.isDirectory()) continue
489
+ target = p
490
+ } catch {
491
+ continue
492
+ }
493
+ let remote: string | undefined
494
+ try {
495
+ const { stdout } = await execFileP("git", ["-C", target, "remote", "get-url", "origin"])
496
+ remote = stdout.trim()
497
+ } catch {}
498
+ out.push({ name, path: target, remote })
499
+ }
500
+ return out
501
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "loopat-builtin",
4
+ "description": "Loopat platform-shipped plugins",
5
+ "owner": { "name": "loopat" },
6
+ "plugins": [
7
+ {
8
+ "name": "loopat",
9
+ "source": "./plugins/loopat",
10
+ "description": "Built-in loopat platform skills (onboarding, help)"
11
+ }
12
+ ]
13
+ }