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,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
+ }