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.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- 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
|
+
}
|