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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute a preview of what a loop with given profiles will contain:
|
|
3
|
+
* plugin count, skill count, agent count, hook count, MCP server count.
|
|
4
|
+
*
|
|
5
|
+
* Aggregates from all sources that will be merged at spawn time:
|
|
6
|
+
* - team: .loopat/.claude/{settings.json, skills/, agents/}
|
|
7
|
+
* - profile: .loopat/profiles/<name>/.claude/{settings.json, skills/, agents/}
|
|
8
|
+
* - personal (skipped — per-user override; not included in pre-create preview)
|
|
9
|
+
*
|
|
10
|
+
* For each enabled plugin, also scans the plugin's source dir (host CC cache
|
|
11
|
+
* OR local marketplace source) to count skills/agents/MCPs/hooks contributed.
|
|
12
|
+
*
|
|
13
|
+
* Result is deduplicated by name (skill "foo" from team + profile counts once).
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
16
|
+
import { readFile } from "node:fs/promises"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { parse as parseToml } from "smol-toml"
|
|
19
|
+
import {
|
|
20
|
+
workspaceTeamClaudeDir,
|
|
21
|
+
workspaceProfileClaudeDir,
|
|
22
|
+
} from "./paths"
|
|
23
|
+
import { lookupPluginInstallPath } from "./plugin-installer"
|
|
24
|
+
|
|
25
|
+
export type LoopStats = {
|
|
26
|
+
plugins: number
|
|
27
|
+
skills: number
|
|
28
|
+
agents: number
|
|
29
|
+
hooks: number
|
|
30
|
+
mcpServers: number
|
|
31
|
+
/** Toolchain tools (mise.toml [tools] entries) declared across all
|
|
32
|
+
* sources, deduped by tool key. */
|
|
33
|
+
toolchain: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Count tools declared in a .claude/mise.toml. Each top-level key under
|
|
38
|
+
* [tools] is one tool (bare like `python = "3.12"` or nested like
|
|
39
|
+
* `[tools."http:a1"]`). Missing file or malformed toml → 0.
|
|
40
|
+
*
|
|
41
|
+
* Exported so listProfilesRich() / getTiers() can reuse the same parse.
|
|
42
|
+
*/
|
|
43
|
+
export function countToolchainTools(claudeDir: string): string[] {
|
|
44
|
+
const p = join(claudeDir, "mise.toml")
|
|
45
|
+
if (!existsSync(p)) return []
|
|
46
|
+
try {
|
|
47
|
+
const raw = require("node:fs").readFileSync(p, "utf8") as string
|
|
48
|
+
const parsed = parseToml(raw) as { tools?: Record<string, unknown> }
|
|
49
|
+
return Object.keys(parsed.tools ?? {})
|
|
50
|
+
} catch {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Settings = {
|
|
56
|
+
enabledPlugins?: Record<string, boolean>
|
|
57
|
+
extraKnownMarketplaces?: Record<string, { source?: any }>
|
|
58
|
+
mcpServers?: Record<string, any>
|
|
59
|
+
hooks?: Record<string, any> | any[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read settings.json from a .claude/ dir. */
|
|
63
|
+
async function readSettings(claudeDir: string): Promise<Settings | null> {
|
|
64
|
+
const p = join(claudeDir, "settings.json")
|
|
65
|
+
if (!existsSync(p)) return null
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(await readFile(p, "utf8")) as Settings
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Count entries in a dir (skipping dotfiles + non-matching). */
|
|
74
|
+
function countDirEntries(dir: string, opts?: { suffix?: string; mustBeDir?: boolean }): string[] {
|
|
75
|
+
if (!existsSync(dir)) return []
|
|
76
|
+
let entries: string[]
|
|
77
|
+
try {
|
|
78
|
+
entries = readdirSync(dir)
|
|
79
|
+
} catch {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
return entries.filter((name) => {
|
|
83
|
+
if (name.startsWith(".")) return false
|
|
84
|
+
if (opts?.suffix && !name.endsWith(opts.suffix)) return false
|
|
85
|
+
if (opts?.mustBeDir) {
|
|
86
|
+
try {
|
|
87
|
+
return statSync(join(dir, name)).isDirectory()
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return true
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Count entries in settings.json hooks field (can be array or object). */
|
|
97
|
+
function countHooks(s: Settings | null): number {
|
|
98
|
+
if (!s?.hooks) return 0
|
|
99
|
+
if (Array.isArray(s.hooks)) return s.hooks.length
|
|
100
|
+
if (typeof s.hooks === "object") {
|
|
101
|
+
let n = 0
|
|
102
|
+
for (const v of Object.values(s.hooks)) {
|
|
103
|
+
if (Array.isArray(v)) n += v.length
|
|
104
|
+
else if (v) n++
|
|
105
|
+
}
|
|
106
|
+
return n
|
|
107
|
+
}
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scan a plugin's directory for skills, agents, hooks, mcpServers.
|
|
113
|
+
* Returns sets of names (so callers can dedupe across plugins/sources).
|
|
114
|
+
*/
|
|
115
|
+
async function scanPlugin(pluginDir: string): Promise<{
|
|
116
|
+
skills: string[]
|
|
117
|
+
agents: string[]
|
|
118
|
+
hooks: number
|
|
119
|
+
mcpServers: string[]
|
|
120
|
+
}> {
|
|
121
|
+
const skills = countDirEntries(join(pluginDir, "skills"), { mustBeDir: true })
|
|
122
|
+
const agents = countDirEntries(join(pluginDir, "agents"), { suffix: ".md" })
|
|
123
|
+
.map((n) => n.replace(/\.md$/, ""))
|
|
124
|
+
|
|
125
|
+
let hooks = 0
|
|
126
|
+
let mcpServers: string[] = []
|
|
127
|
+
|
|
128
|
+
// Plugin-shipped mcpServers come from the plugin's settings.json mcpServers
|
|
129
|
+
// field (loopat doesn't read `.mcp.json` — that file format is deprecated
|
|
130
|
+
// here in favor of a single unified settings.json across all tiers).
|
|
131
|
+
const settingsPath = join(pluginDir, "settings.json")
|
|
132
|
+
if (existsSync(settingsPath)) {
|
|
133
|
+
try {
|
|
134
|
+
const j = JSON.parse(await readFile(settingsPath, "utf8"))
|
|
135
|
+
mcpServers = Object.keys(j?.mcpServers ?? {})
|
|
136
|
+
} catch {}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Plugin hooks/ dir or hooks.json
|
|
140
|
+
const hooksJson = join(pluginDir, "hooks", "hooks.json")
|
|
141
|
+
if (existsSync(hooksJson)) {
|
|
142
|
+
try {
|
|
143
|
+
const j = JSON.parse(await readFile(hooksJson, "utf8"))
|
|
144
|
+
if (Array.isArray(j?.hooks)) hooks = j.hooks.length
|
|
145
|
+
else if (typeof j?.hooks === "object") {
|
|
146
|
+
for (const v of Object.values(j.hooks)) {
|
|
147
|
+
if (Array.isArray(v)) hooks += v.length
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { skills, agents, hooks, mcpServers }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Main entry: compute the totals for a hypothetical loop with the given
|
|
158
|
+
* non-base profiles (team is always implicit). Returns deduped counts.
|
|
159
|
+
*/
|
|
160
|
+
export async function computeLoopStats(profiles: string[]): Promise<LoopStats> {
|
|
161
|
+
// Collect all source .claude/ dirs to scan
|
|
162
|
+
const sources: Array<{ source: string; dir: string }> = []
|
|
163
|
+
const teamDir = workspaceTeamClaudeDir()
|
|
164
|
+
if (existsSync(teamDir)) sources.push({ source: "team", dir: teamDir })
|
|
165
|
+
for (const p of profiles) {
|
|
166
|
+
const d = workspaceProfileClaudeDir(p)
|
|
167
|
+
if (existsSync(d)) sources.push({ source: `profile:${p}`, dir: d })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Sets to dedupe across sources
|
|
171
|
+
const enabledPluginSet = new Set<string>()
|
|
172
|
+
const skillSet = new Set<string>()
|
|
173
|
+
const agentSet = new Set<string>()
|
|
174
|
+
const mcpServerSet = new Set<string>()
|
|
175
|
+
const toolchainSet = new Set<string>()
|
|
176
|
+
let hookCount = 0
|
|
177
|
+
|
|
178
|
+
for (const s of sources) {
|
|
179
|
+
const settings = await readSettings(s.dir)
|
|
180
|
+
if (settings?.enabledPlugins) {
|
|
181
|
+
for (const [k, v] of Object.entries(settings.enabledPlugins)) {
|
|
182
|
+
if (v) enabledPluginSet.add(k)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (settings?.mcpServers) {
|
|
186
|
+
for (const k of Object.keys(settings.mcpServers)) mcpServerSet.add(k)
|
|
187
|
+
}
|
|
188
|
+
hookCount += countHooks(settings)
|
|
189
|
+
|
|
190
|
+
// Loose skills + agents at the source level (not from plugins)
|
|
191
|
+
for (const name of countDirEntries(join(s.dir, "skills"), { mustBeDir: true })) {
|
|
192
|
+
skillSet.add(name)
|
|
193
|
+
}
|
|
194
|
+
for (const name of countDirEntries(join(s.dir, "agents"), { suffix: ".md" })) {
|
|
195
|
+
agentSet.add(name.replace(/\.md$/, ""))
|
|
196
|
+
}
|
|
197
|
+
// Toolchain tools from this tier's mise.toml (last-wins semantics for mise
|
|
198
|
+
// overrides happen at compose time; for the preview "how many distinct
|
|
199
|
+
// tools will end up in PATH", we dedupe by key — which matches the merged
|
|
200
|
+
// toolchain since later tiers overwrite same-keyed entries).
|
|
201
|
+
for (const tool of countToolchainTools(s.dir)) {
|
|
202
|
+
toolchainSet.add(tool)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Now scan each enabled plugin for its contributions
|
|
207
|
+
for (const spec of enabledPluginSet) {
|
|
208
|
+
const pluginDir = await lookupPluginInstallPath(spec)
|
|
209
|
+
if (!pluginDir) continue
|
|
210
|
+
const scan = await scanPlugin(pluginDir)
|
|
211
|
+
for (const s of scan.skills) skillSet.add(`${spec.split("@")[0]}:${s}`)
|
|
212
|
+
for (const a of scan.agents) agentSet.add(`${spec.split("@")[0]}:${a}`)
|
|
213
|
+
for (const m of scan.mcpServers) mcpServerSet.add(m)
|
|
214
|
+
hookCount += scan.hooks
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
plugins: enabledPluginSet.size,
|
|
219
|
+
skills: skillSet.size,
|
|
220
|
+
agents: agentSet.size,
|
|
221
|
+
hooks: hookCount,
|
|
222
|
+
mcpServers: mcpServerSet.size,
|
|
223
|
+
toolchain: toolchainSet.size,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, watch } from "node:fs"
|
|
3
|
+
import { LOOPAT_HOME } from "./paths"
|
|
4
|
+
|
|
5
|
+
export type LoopStatusEntry = { status: string; updated: string; viewed?: boolean }
|
|
6
|
+
export type LoopStatusMap = Record<string, LoopStatusEntry>
|
|
7
|
+
|
|
8
|
+
const STATUS_FILE = join(LOOPAT_HOME, "loop-status.json")
|
|
9
|
+
let cache: LoopStatusMap = {}
|
|
10
|
+
const watchers = new Set<(curr: LoopStatusMap, prev: LoopStatusMap) => void>()
|
|
11
|
+
|
|
12
|
+
if (existsSync(STATUS_FILE)) {
|
|
13
|
+
try { cache = JSON.parse(readFileSync(STATUS_FILE, "utf8")) } catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function save() {
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(STATUS_FILE, JSON.stringify(cache, null, 2))
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.error("[loop-status] Failed to write file:", e)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function updateLoopStatus(loopId: string, status: string) {
|
|
25
|
+
const prev = { ...cache }
|
|
26
|
+
const entry = cache[loopId] || { status: "", updated: "", viewed: false }
|
|
27
|
+
entry.status = status
|
|
28
|
+
entry.updated = new Date().toISOString()
|
|
29
|
+
if (status === "Done") {
|
|
30
|
+
entry.viewed = false
|
|
31
|
+
}
|
|
32
|
+
cache[loopId] = entry
|
|
33
|
+
save()
|
|
34
|
+
|
|
35
|
+
// Immediately notify watchers without waiting for file system event
|
|
36
|
+
for (const fn of watchers) {
|
|
37
|
+
fn(cache, prev)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function markLoopViewed(loopId: string) {
|
|
42
|
+
if (cache[loopId]) {
|
|
43
|
+
cache[loopId].viewed = true
|
|
44
|
+
save()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getLoopStatus(): LoopStatusMap {
|
|
49
|
+
return cache
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function watchStatusFile(fn: (curr: LoopStatusMap, prev: LoopStatusMap) => void) {
|
|
53
|
+
watchers.add(fn)
|
|
54
|
+
let prev = { ...cache }
|
|
55
|
+
try {
|
|
56
|
+
watch(STATUS_FILE, (eventType) => {
|
|
57
|
+
if (eventType === "change") {
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(STATUS_FILE, "utf8")
|
|
60
|
+
const curr = JSON.parse(raw)
|
|
61
|
+
fn(curr, prev)
|
|
62
|
+
prev = curr
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|