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,190 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LOOPAT_HOME *is* the workspace directory. Single-workspace by design — to
|
|
7
|
+
* run a second workspace, start a second loopat instance with a different
|
|
8
|
+
* LOOPAT_HOME. Default `~/.loopat`. The display/URL name is the basename
|
|
9
|
+
* with leading dots stripped (so `~/.loopat` → "loopat").
|
|
10
|
+
*/
|
|
11
|
+
export const LOOPAT_HOME = process.env.LOOPAT_HOME ?? join(homedir(), ".loopat")
|
|
12
|
+
|
|
13
|
+
// loopat code install dir (contains node_modules/, helper binaries the sandbox needs)
|
|
14
|
+
// Computed from this file's path: server/src/paths.ts → loop/
|
|
15
|
+
const __DIRNAME = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
export const LOOPAT_INSTALL_DIR = resolve(__DIRNAME, "../..")
|
|
17
|
+
export const TEMPLATES_DIR = join(LOOPAT_INSTALL_DIR, "server", "templates")
|
|
18
|
+
|
|
19
|
+
export const WORKSPACE = basename(LOOPAT_HOME).replace(/^\.+/, "") || "loopat"
|
|
20
|
+
|
|
21
|
+
export const workspaceDir = () => LOOPAT_HOME
|
|
22
|
+
export const usersPath = () => join(LOOPAT_HOME, "users.json")
|
|
23
|
+
export const loopsDir = () => join(LOOPAT_HOME, "loops")
|
|
24
|
+
export const workspaceContextDir = () => join(LOOPAT_HOME, "context")
|
|
25
|
+
export const workspaceKnowledgeDir = () => join(workspaceContextDir(), "knowledge")
|
|
26
|
+
export const workspaceNotesDir = () => join(workspaceContextDir(), "notes")
|
|
27
|
+
export const workspaceReposDir = () => join(workspaceContextDir(), "repos")
|
|
28
|
+
export const workspaceRepoDir = (name: string) => join(workspaceReposDir(), name)
|
|
29
|
+
// Local git hosting: when a context repo has no remote, loopat hosts the
|
|
30
|
+
// `origin` itself as a bare repo here (docs/context-flow.md "solo").
|
|
31
|
+
export const workspaceOriginsDir = () => join(LOOPAT_HOME, "origins")
|
|
32
|
+
export const workspaceOriginPath = (name: string) => join(workspaceOriginsDir(), `${name}.git`)
|
|
33
|
+
// External git-host provider extensions (e.g. internal "Code" platform). Drop a
|
|
34
|
+
// duck-typed provider file here; loopat loads it without any core change.
|
|
35
|
+
export const extensionsProvidersDir = () => join(LOOPAT_HOME, "extensions", "providers")
|
|
36
|
+
export const personalDir = (user: string) => join(LOOPAT_HOME, "personal", user)
|
|
37
|
+
|
|
38
|
+
export const loopDir = (id: string) => join(loopsDir(), id)
|
|
39
|
+
export const loopWorkdir = (id: string) => join(loopDir(id), "workdir")
|
|
40
|
+
export const loopClaudeDir = (id: string) => join(loopDir(id), ".claude")
|
|
41
|
+
export const loopContextDir = (id: string) => join(loopDir(id), "context")
|
|
42
|
+
export const loopContextKnowledge = (id: string) => join(loopContextDir(id), "knowledge")
|
|
43
|
+
export const loopContextNotes = (id: string) => join(loopContextDir(id), "notes")
|
|
44
|
+
export const loopContextPersonal = (id: string) => join(loopContextDir(id), "personal")
|
|
45
|
+
export const loopContextRepos = (id: string) => join(loopContextDir(id), "repos")
|
|
46
|
+
export const loopContextChatDir = (id: string) => join(loopContextDir(id), "chat")
|
|
47
|
+
export const loopMetaPath = (id: string) => join(loopDir(id), "meta.json")
|
|
48
|
+
|
|
49
|
+
// UI loop checkouts — a per-user worktree for editing team context (notes) from
|
|
50
|
+
// outside any AI loop (a "no-AI UI loop", see docs/context-flow.md). Disposable:
|
|
51
|
+
// opened from origin/main, synced back ff-only.
|
|
52
|
+
export const uiDir = (user: string) => join(LOOPAT_HOME, "ui", user)
|
|
53
|
+
export const uiNotesDir = (user: string) => join(uiDir(user), "notes")
|
|
54
|
+
export const loopHistoryPath = (id: string) => join(loopDir(id), "messages.jsonl")
|
|
55
|
+
export const loopChatHistoryPath = (id: string) => join(loopDir(id), "chat_history.jsonl")
|
|
56
|
+
|
|
57
|
+
export const chatDbPath = () => join(LOOPAT_HOME, "chat.db")
|
|
58
|
+
|
|
59
|
+
export const personalMemoryDir = (user: string) => join(personalDir(user), "memory")
|
|
60
|
+
export const workspaceMemoryDir = () => join(workspaceNotesDir(), "memory")
|
|
61
|
+
// (Old `workspaceLoopat{Reserved,Claude,Skills,Agents,Sandbox*}` helpers
|
|
62
|
+
// deleted — superseded by the tiered .claude/ model. Workspace CC config now
|
|
63
|
+
// lives at `<knowledge>/.loopat/.claude/` and is accessed via
|
|
64
|
+
// `workspaceTeamClaudeDir/SettingsPath/ClaudeMdPath/SkillsDir/AgentsDir` below.)
|
|
65
|
+
|
|
66
|
+
// Per-loop $HOME overlay (docker container layer for home). The sandbox's
|
|
67
|
+
// $HOME is an overlayfs mount: lower = workspaceHomeSkelDir (shared skeleton,
|
|
68
|
+
// typically empty), upper = home-upper (per-loop persistent diff), work =
|
|
69
|
+
// home-work (overlayfs internal scratch). merged is the mount point that
|
|
70
|
+
// bwrap binds into the sandbox at $HOME. Persists across loop restarts; AI's
|
|
71
|
+
// pip/npm installs and shell history survive.
|
|
72
|
+
export const loopHomeUpper = (id: string) => join(loopDir(id), "home-upper")
|
|
73
|
+
export const loopHomeWork = (id: string) => join(loopDir(id), "home-work")
|
|
74
|
+
export const loopHomeMerged = (id: string) => join(loopDir(id), "home-merged")
|
|
75
|
+
// Workspace-shared base layer for the home overlay. User can drop default
|
|
76
|
+
// dotfiles in here; left empty by default.
|
|
77
|
+
export const workspaceHomeSkelDir = () => join(LOOPAT_HOME, "sandbox-home-skel")
|
|
78
|
+
// Bundled platform doctrine — ships with loopat code, always present.
|
|
79
|
+
export const bundledDoctrinePath = () => join(TEMPLATES_DIR, "CLAUDE.md")
|
|
80
|
+
|
|
81
|
+
// Per-loop-kind templates (distill, future: review, plan, etc.). Each kind
|
|
82
|
+
// has its own dir; createLoop / distillLoop copies the kind's CLAUDE.md into
|
|
83
|
+
// the new loop's workdir as the L2++ project-tier doctrine.
|
|
84
|
+
export const loopKindTemplateDir = (kind: string) => join(TEMPLATES_DIR, "loop-kinds", kind)
|
|
85
|
+
export const loopKindClaudePath = (kind: string) => join(loopKindTemplateDir(kind), "CLAUDE.md")
|
|
86
|
+
|
|
87
|
+
// Personal `.loopat/` reserved namespace: per-user loopat config + vaults.
|
|
88
|
+
// Mirrors `knowledge/.loopat/` as the personal counterpart.
|
|
89
|
+
//
|
|
90
|
+
// Vault model: each loop selects one vault (default = "default"). The vault is
|
|
91
|
+
// NOT exposed to the sandbox as a directory. Instead two filesystem conventions
|
|
92
|
+
// inside the vault drive automatic delivery at spawn time:
|
|
93
|
+
// - `vaults/<v>/envs/<NAME>` → injected as env var $NAME
|
|
94
|
+
// - `vaults/<v>/mounts/home/<rel>/...` → bound at $HOME/<rel>/...
|
|
95
|
+
// AI never sees "vault" as a concept — it just sees a configured machine.
|
|
96
|
+
export const personalLoopatDir = (user: string) => join(personalDir(user), ".loopat")
|
|
97
|
+
export const personalLoopatConfigPath = (user: string) => join(personalLoopatDir(user), "config.json")
|
|
98
|
+
export const personalVaultsDir = (user: string) => join(personalLoopatDir(user), "vaults")
|
|
99
|
+
// Personal `.claude/` — CC-native shape. The 4th layer in loopat's tiered
|
|
100
|
+
// .claude merge (workspace + profiles + personal + repo). Lives under
|
|
101
|
+
// `.loopat/` to mirror the team convention (`knowledge/.loopat/.claude/`):
|
|
102
|
+
// loopat-controlled config goes under `.loopat/` so the personal repo's
|
|
103
|
+
// other content (memory, scratch files) stays cleanly separate.
|
|
104
|
+
// Contains: settings.json, CLAUDE.md, skills/, agents/.
|
|
105
|
+
export const personalClaudeDir = (user: string) => join(personalLoopatDir(user), ".claude")
|
|
106
|
+
export const personalClaudeMdPath = (user: string) => join(personalClaudeDir(user), "CLAUDE.md")
|
|
107
|
+
export const personalSettingsPath = (user: string) => join(personalClaudeDir(user), "settings.json")
|
|
108
|
+
export const personalSkillsDir = (user: string) => join(personalClaudeDir(user), "skills")
|
|
109
|
+
export const personalAgentsDir = (user: string) => join(personalClaudeDir(user), "agents")
|
|
110
|
+
// Composed output inside each loop's .claude/. Regenerated every spawn.
|
|
111
|
+
// Plugin loading does NOT touch the loop's .claude/ — SDK loads plugins via
|
|
112
|
+
// its `plugins` option (resolved from server cache; see plugin-installer.ts).
|
|
113
|
+
export const loopComposedSkillsDir = (id: string) => join(loopDir(id), ".claude", "skills")
|
|
114
|
+
export const loopComposedAgentsDir = (id: string) => join(loopDir(id), ".claude", "agents")
|
|
115
|
+
|
|
116
|
+
// Platform-shipped builtin plugins live under server/templates/plugins/<name>/.
|
|
117
|
+
// They're always loaded into every loop (plugin-installer.ts:resolveBuiltinPlugins).
|
|
118
|
+
// No marketplace wrapper — direct path injection via SDK plugins option.
|
|
119
|
+
export const personalVaultDir = (user: string, vault: string) => join(personalVaultsDir(user), vault)
|
|
120
|
+
/** Convention dir: every file under this dir is auto-injected as an env var
|
|
121
|
+
* at spawn time. Filename = env var name. content = value (trailing newline
|
|
122
|
+
* stripped). Subdirs not recursed. */
|
|
123
|
+
export const personalVaultEnvsDir = (user: string, vault: string) =>
|
|
124
|
+
join(personalVaultDir(user, vault), "envs")
|
|
125
|
+
/** Path to a specific env-var file inside the vault. */
|
|
126
|
+
export const personalVaultEnvPath = (user: string, vault: string, name: string) =>
|
|
127
|
+
join(personalVaultEnvsDir(user, vault), name)
|
|
128
|
+
/** Convention dir: every top-level entry under this is auto-bound at the
|
|
129
|
+
* corresponding $HOME-relative path. e.g. `mounts/home/.ssh/` → `$HOME/.ssh/`. */
|
|
130
|
+
export const personalVaultMountsHomeDir = (user: string, vault: string) =>
|
|
131
|
+
join(personalVaultDir(user, vault), "mounts", "home")
|
|
132
|
+
|
|
133
|
+
// Host-only per-user state: deploy key (loopat → personal repo) and git-crypt
|
|
134
|
+
// key (decrypts secrets/ inside the cloned personal repo). Kept OUTSIDE
|
|
135
|
+
// personal/<user>/ so it never appears in the sandbox bind view. The user
|
|
136
|
+
// can't see these from inside their loop's terminal / file browser.
|
|
137
|
+
export const hostSecretsDir = (user: string) => join(LOOPAT_HOME, "host-secrets", user)
|
|
138
|
+
export const hostDeployKeyPath = (user: string) => join(hostSecretsDir(user), "deploy-key")
|
|
139
|
+
export const hostDeployKeyPubPath = (user: string) => join(hostSecretsDir(user), "deploy-key.pub")
|
|
140
|
+
export const personalGitCryptKeyPath = (user: string) => join(hostSecretsDir(user), "git-crypt.key")
|
|
141
|
+
export const personalTokenUsagePath = (user: string) => join(personalLoopatDir(user), "token-usage.json")
|
|
142
|
+
export const workspaceSecretsDir = () => join(workspaceDir(), "secrets")
|
|
143
|
+
|
|
144
|
+
// ─── Profile composition model (post-2026-05 design, CC-native refactor) ─
|
|
145
|
+
//
|
|
146
|
+
// See docs/composition.md. The team workspace lives inside the
|
|
147
|
+
// knowledge git repo at `.loopat/`, structured as a stack of CC-native
|
|
148
|
+
// `.claude/` directories — one per tier (team / profile). loopat materializes
|
|
149
|
+
// a merge of selected tiers into each loop's `.claude/`.
|
|
150
|
+
//
|
|
151
|
+
// knowledge/.loopat/
|
|
152
|
+
// .claude/ ← team-tier CC config
|
|
153
|
+
// settings.json (enabledPlugins, extraKnownMarketplaces)
|
|
154
|
+
// CLAUDE.md, skills/, agents/
|
|
155
|
+
// profiles/<name>/.claude/ ← profile-tier CC config
|
|
156
|
+
// settings.json, CLAUDE.md, skills/, agents/
|
|
157
|
+
// marketplace/ ← team's local CC marketplace
|
|
158
|
+
// .claude-plugin/marketplace.json
|
|
159
|
+
// <plugin>/...
|
|
160
|
+
//
|
|
161
|
+
// No loopat-invented schema (profile.json gone). Admins use CC's own
|
|
162
|
+
// commands (`claude plugin install --scope=project` etc.) inside these
|
|
163
|
+
// dirs to edit team / profile configuration.
|
|
164
|
+
export const workspaceLoopatRoot = () => join(workspaceKnowledgeDir(), ".loopat")
|
|
165
|
+
|
|
166
|
+
// Team-tier .claude/ — analogous to ~/.claude/ but shared across team via git.
|
|
167
|
+
export const workspaceTeamClaudeDir = () => join(workspaceLoopatRoot(), ".claude")
|
|
168
|
+
export const workspaceTeamSettingsPath = () => join(workspaceTeamClaudeDir(), "settings.json")
|
|
169
|
+
export const workspaceTeamClaudeMdPath = () => join(workspaceTeamClaudeDir(), "CLAUDE.md")
|
|
170
|
+
export const workspaceTeamSkillsDir = () => join(workspaceTeamClaudeDir(), "skills")
|
|
171
|
+
export const workspaceTeamAgentsDir = () => join(workspaceTeamClaudeDir(), "agents")
|
|
172
|
+
|
|
173
|
+
// Profiles — each is a dir with a `.claude/` subdir (CC project-tier shape).
|
|
174
|
+
export const workspaceProfilesDir = () => join(workspaceLoopatRoot(), "profiles")
|
|
175
|
+
export const workspaceProfileDir = (name: string) => join(workspaceProfilesDir(), name)
|
|
176
|
+
export const workspaceProfileClaudeDir = (name: string) =>
|
|
177
|
+
join(workspaceProfileDir(name), ".claude")
|
|
178
|
+
export const workspaceProfileSettingsPath = (name: string) =>
|
|
179
|
+
join(workspaceProfileClaudeDir(name), "settings.json")
|
|
180
|
+
export const workspaceProfileClaudeMdPath = (name: string) =>
|
|
181
|
+
join(workspaceProfileClaudeDir(name), "CLAUDE.md")
|
|
182
|
+
export const workspaceProfileSkillsDir = (name: string) =>
|
|
183
|
+
join(workspaceProfileClaudeDir(name), "skills")
|
|
184
|
+
export const workspaceProfileAgentsDir = (name: string) =>
|
|
185
|
+
join(workspaceProfileClaudeDir(name), "agents")
|
|
186
|
+
|
|
187
|
+
// (Marketplace location is NOT a loopat convention. Teams choose where to
|
|
188
|
+
// host private plugins — typically `<knowledge>/marketplace/` — and declare
|
|
189
|
+
// it via `extraKnownMarketplaces` in their team `.claude/settings.json`.
|
|
190
|
+
// loopat just registers whatever's declared; it doesn't probe fixed paths.)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopat-managed SSH keypair for the user's personal git repo (deploy key).
|
|
3
|
+
*
|
|
4
|
+
* Lives under `host-secrets/<user>/deploy-key` — OUTSIDE personal/<user>/ so
|
|
5
|
+
* it never enters the sandbox bind view. The user can't see this key from
|
|
6
|
+
* inside their loop. It's loopat-the-platform's clone credential, not a
|
|
7
|
+
* user-owned tool.
|
|
8
|
+
*
|
|
9
|
+
* Public key is rendered to the UI once at register time so the user can
|
|
10
|
+
* register it as a deploy key on their personal git repo (aone / github).
|
|
11
|
+
*
|
|
12
|
+
* Idempotent: if the keypair already exists, returns the existing public key.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs"
|
|
15
|
+
import { mkdir, readFile, chmod } from "node:fs/promises"
|
|
16
|
+
import { execFile } from "node:child_process"
|
|
17
|
+
import { promisify } from "node:util"
|
|
18
|
+
import {
|
|
19
|
+
hostSecretsDir,
|
|
20
|
+
hostDeployKeyPath,
|
|
21
|
+
hostDeployKeyPubPath,
|
|
22
|
+
} from "./paths"
|
|
23
|
+
|
|
24
|
+
const execFileP = promisify(execFile)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate ed25519 keypair if missing. Tolerant: if `ssh-keygen` is not on
|
|
28
|
+
* PATH (host missing openssh-client) returns `{ publicKey: null }` so the
|
|
29
|
+
* rest of register/provision proceeds — user can install ssh-keygen later
|
|
30
|
+
* and retrigger key gen via /api/personal/import (which calls this again).
|
|
31
|
+
*/
|
|
32
|
+
export async function ensurePersonalKeypair(userId: string): Promise<{ publicKey: string | null }> {
|
|
33
|
+
const dir = hostSecretsDir(userId)
|
|
34
|
+
const priv = hostDeployKeyPath(userId)
|
|
35
|
+
const pub = hostDeployKeyPubPath(userId)
|
|
36
|
+
|
|
37
|
+
await mkdir(dir, { recursive: true })
|
|
38
|
+
await chmod(dir, 0o700).catch(() => {})
|
|
39
|
+
|
|
40
|
+
if (!existsSync(priv) || !existsSync(pub)) {
|
|
41
|
+
const comment = `loopat:${userId}`
|
|
42
|
+
try {
|
|
43
|
+
await execFileP("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", comment, "-f", priv, "-q"])
|
|
44
|
+
} catch (e: any) {
|
|
45
|
+
console.warn(`[loopat] ssh-keygen failed for user=${userId}: ${e?.message ?? e}. Install openssh-client to enable deploy-key flow.`)
|
|
46
|
+
return { publicKey: null }
|
|
47
|
+
}
|
|
48
|
+
await chmod(priv, 0o600).catch(() => {})
|
|
49
|
+
await chmod(pub, 0o644).catch(() => {})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const publicKey = (await readFile(pub, "utf8")).trim()
|
|
53
|
+
return { publicKey }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function getPublicKey(userId: string): Promise<string | null> {
|
|
57
|
+
const pub = hostDeployKeyPubPath(userId)
|
|
58
|
+
if (!existsSync(pub)) return null
|
|
59
|
+
return (await readFile(pub, "utf8")).trim()
|
|
60
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin orchestration on the host. Post-wholesale-bind (2026-05): the inner
|
|
3
|
+
* SDK now resolves enabledPlugins natively because bwrap.ts ro-binds
|
|
4
|
+
* ~/.claude/plugins/ wholesale into the sandbox. So loopat's job here is
|
|
5
|
+
* purely host-side preparation:
|
|
6
|
+
*
|
|
7
|
+
* 1. Make sure every marketplace declared in the loop's merged
|
|
8
|
+
* `extraKnownMarketplaces` is registered with the host CC.
|
|
9
|
+
* 2. Make sure every spec in `enabledPlugins` is installed in the host CC
|
|
10
|
+
* cache (otherwise SDK would find an enabled-but-uninstalled spec and
|
|
11
|
+
* fail to load it).
|
|
12
|
+
*
|
|
13
|
+
* That's it — no path resolution, no return value beyond success/failure. The
|
|
14
|
+
* `plugins:` SDK option is reserved for the loopat-shipped builtin (which
|
|
15
|
+
* lives under LOOPAT_INSTALL_DIR, not in CC's plugin cache).
|
|
16
|
+
*
|
|
17
|
+
* `lookupPluginInstallPath` remains a host-side utility for the slash-command
|
|
18
|
+
* pre-seed (session.ts) and the loop-stats preview (loop-stats.ts).
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, statSync } from "node:fs"
|
|
21
|
+
import { readFile } from "node:fs/promises"
|
|
22
|
+
import { execFile } from "node:child_process"
|
|
23
|
+
import { homedir } from "node:os"
|
|
24
|
+
import { join, resolve as resolvePath } from "node:path"
|
|
25
|
+
import { promisify } from "node:util"
|
|
26
|
+
import { TEMPLATES_DIR, loopClaudeDir } from "./paths"
|
|
27
|
+
|
|
28
|
+
const execFileP = promisify(execFile)
|
|
29
|
+
|
|
30
|
+
/** loopat-shipped builtin plugin (not in CC's plugin cache; passed via SDK option). */
|
|
31
|
+
export const BUILTIN_LOOPAT_PLUGIN_PATH = join(TEMPLATES_DIR, "plugins", "loopat")
|
|
32
|
+
|
|
33
|
+
const USER_CLAUDE_DIR = join(homedir(), ".claude")
|
|
34
|
+
const USER_INSTALLED_PLUGINS = join(USER_CLAUDE_DIR, "plugins", "installed_plugins.json")
|
|
35
|
+
const USER_KNOWN_MARKETPLACES = join(USER_CLAUDE_DIR, "plugins", "known_marketplaces.json")
|
|
36
|
+
|
|
37
|
+
type InstalledPluginsFile = {
|
|
38
|
+
version: number
|
|
39
|
+
plugins: Record<string, Array<{ installPath: string; version: string; scope?: string }>>
|
|
40
|
+
}
|
|
41
|
+
type KnownMarketplacesFile = Record<string, { installLocation?: string; source?: any }>
|
|
42
|
+
type MarketplaceCatalog = {
|
|
43
|
+
plugins?: Array<{ name: string; source: string | { source: string; [k: string]: any } }>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readJsonOpt<T>(path: string): Promise<T | null> {
|
|
47
|
+
if (!existsSync(path)) return null
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(await readFile(path, "utf8")) as T
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function runClaude(args: string[]): Promise<{ ok: boolean; out: string; err: string }> {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout, stderr } = await execFileP("claude", args)
|
|
58
|
+
return { ok: true, out: stdout, err: stderr }
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
out: e?.stdout?.toString?.() ?? "",
|
|
63
|
+
err: e?.stderr?.toString?.() ?? e?.message ?? String(e),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compare two marketplace sources (from settings.json and from CC's
|
|
70
|
+
* known_marketplaces.json). Used to detect URL/path drift — if a team admin
|
|
71
|
+
* changes the marketplace URL, members' host CC needs to re-register.
|
|
72
|
+
*/
|
|
73
|
+
export function sourcesMatch(declared: any, existing: any): boolean {
|
|
74
|
+
if (declared === existing) return true
|
|
75
|
+
if (!declared || !existing) return false
|
|
76
|
+
if (typeof declared !== "object" || typeof existing !== "object") return false
|
|
77
|
+
if (declared.source !== existing.source) return false
|
|
78
|
+
switch (declared.source) {
|
|
79
|
+
case "git":
|
|
80
|
+
case "url":
|
|
81
|
+
return declared.url === existing.url
|
|
82
|
+
case "github":
|
|
83
|
+
return (declared.repo ?? declared.repository) === (existing.repo ?? existing.repository)
|
|
84
|
+
case "directory":
|
|
85
|
+
return declared.path === existing.path
|
|
86
|
+
default:
|
|
87
|
+
return JSON.stringify(declared) === JSON.stringify(existing)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ensureMarketplace(
|
|
92
|
+
name: string,
|
|
93
|
+
addPath: string,
|
|
94
|
+
declaredSource: any,
|
|
95
|
+
km: KnownMarketplacesFile | null,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const existing = (km?.[name] as any)?.source
|
|
98
|
+
if (existing) {
|
|
99
|
+
if (sourcesMatch(declaredSource, existing)) return
|
|
100
|
+
console.warn(
|
|
101
|
+
`[plugins] marketplace "${name}" source drift; re-registering ` +
|
|
102
|
+
`(was ${JSON.stringify(existing)}, want ${JSON.stringify(declaredSource)})`,
|
|
103
|
+
)
|
|
104
|
+
await runClaude(["plugin", "marketplace", "remove", name])
|
|
105
|
+
}
|
|
106
|
+
const add = await runClaude(["plugin", "marketplace", "add", addPath])
|
|
107
|
+
if (!add.ok) {
|
|
108
|
+
console.warn(`[plugins] failed to register marketplace "${name}": ${add.err}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function ensureExtraMarketplaces(
|
|
113
|
+
extras: Record<string, { source?: any }> | undefined,
|
|
114
|
+
loopId: string,
|
|
115
|
+
km: KnownMarketplacesFile | null,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
if (!extras) return
|
|
118
|
+
for (const [name, entry] of Object.entries(extras)) {
|
|
119
|
+
const src = entry?.source as any
|
|
120
|
+
let addPath: string | undefined
|
|
121
|
+
let normalized: any = src
|
|
122
|
+
if (typeof src === "string") {
|
|
123
|
+
addPath = src
|
|
124
|
+
normalized = { source: "github", repo: src }
|
|
125
|
+
} else if (src?.source === "directory" && typeof src.path === "string") {
|
|
126
|
+
addPath = resolvePath(loopClaudeDir(loopId), src.path)
|
|
127
|
+
normalized = { source: "directory", path: addPath }
|
|
128
|
+
} else if (src?.source === "github" && typeof src.repo === "string") {
|
|
129
|
+
addPath = src.repo
|
|
130
|
+
normalized = { source: "github", repo: src.repo }
|
|
131
|
+
} else if ((src?.source === "git" || src?.source === "url") && typeof src.url === "string") {
|
|
132
|
+
addPath = src.url
|
|
133
|
+
normalized = { source: src.source, url: src.url }
|
|
134
|
+
}
|
|
135
|
+
if (!addPath) {
|
|
136
|
+
console.warn(`[plugins] extraKnownMarketplaces["${name}"]: unsupported source shape, skip`)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
await ensureMarketplace(name, addPath, normalized, km)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Install + version-pin check.
|
|
145
|
+
*
|
|
146
|
+
* `lockedVersions` maps spec → required version label, sourced from the loop's
|
|
147
|
+
* snapshot installed_plugins.json (compose merged it from team/profile/personal
|
|
148
|
+
* tiers). When provided we check the host already has that exact version in
|
|
149
|
+
* its CC cache; if not, run `claude plugin install` and verify the resulting
|
|
150
|
+
* version matches.
|
|
151
|
+
*
|
|
152
|
+
* Mismatch is loud-failure (option a — see docs/composition.md). The recovery
|
|
153
|
+
* is for the user to either restore the pinned version manually (e.g. via
|
|
154
|
+
* marketplace clone checkout dance) or for admin to bump the team lock.
|
|
155
|
+
* Option b (loopat does the checkout dance automatically) is a future add.
|
|
156
|
+
*
|
|
157
|
+
* When `lockedVersions[spec]` is null/undefined: no version pin → fall back to
|
|
158
|
+
* the old "install if missing" behavior.
|
|
159
|
+
*/
|
|
160
|
+
async function ensurePluginsInstalled(
|
|
161
|
+
specs: string[],
|
|
162
|
+
ip: InstalledPluginsFile | null,
|
|
163
|
+
lockedVersions: Record<string, string | undefined>,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
if (specs.length === 0) return
|
|
166
|
+
const installedKeys = new Set(Object.keys(ip?.plugins ?? {}))
|
|
167
|
+
for (const spec of specs) {
|
|
168
|
+
const wantVersion = lockedVersions[spec]
|
|
169
|
+
const hostEntry = ip?.plugins?.[spec]?.[0]
|
|
170
|
+
const hostVersion = hostEntry?.version
|
|
171
|
+
|
|
172
|
+
if (wantVersion && hostVersion === wantVersion) continue // pinned + matches
|
|
173
|
+
if (!wantVersion && installedKeys.has(spec)) continue // not pinned + installed
|
|
174
|
+
|
|
175
|
+
const r = await runClaude(["plugin", "install", spec, "--scope=user"])
|
|
176
|
+
if (!r.ok) {
|
|
177
|
+
console.warn(`[plugins] install failed for "${spec}": ${r.err.trim().split("\n").slice(-2).join(" | ")}`)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Verify post-install version matches the pin, if there is one.
|
|
182
|
+
if (wantVersion) {
|
|
183
|
+
const ip2 = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS)
|
|
184
|
+
const got = ip2?.plugins?.[spec]?.[0]?.version
|
|
185
|
+
if (got !== wantVersion) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`[plugins] version mismatch for "${spec}": loop pinned "${wantVersion}", ` +
|
|
188
|
+
`host installed "${got ?? "<unknown>"}". CC's marketplace clone has likely ` +
|
|
189
|
+
`advanced past the pinned version. Options:\n` +
|
|
190
|
+
` 1. Admin: bump the team lock — set this spec's version to "${got ?? "<new>"}" ` +
|
|
191
|
+
`in knowledge/.loopat/.claude/plugins/installed_plugins.json + git push knowledge.\n` +
|
|
192
|
+
` 2. Member: restore the pinned version manually — checkout marketplace at the ` +
|
|
193
|
+
`pinned gitCommitSha, run \`claude plugin install ${spec}\`, then checkout master.`,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Mtime cache on loops/<id>/.claude/settings.json — we only re-run marketplace
|
|
202
|
+
* registration + install when compose actually rewrote settings.
|
|
203
|
+
*/
|
|
204
|
+
type EnsureCacheEntry = { mtime: number }
|
|
205
|
+
const ensureCache = new Map<string, EnsureCacheEntry>()
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Idempotently ensure the host CC has every marketplace + enabled plugin
|
|
209
|
+
* installed that the loop's merged settings.json declares. No return value —
|
|
210
|
+
* the inner SDK resolves enabledPlugins natively at spawn time (via the
|
|
211
|
+
* wholesale ~/.claude/plugins/ bind in bwrap.ts).
|
|
212
|
+
*/
|
|
213
|
+
export async function ensureLoopPluginsInstalled(loopId: string): Promise<void> {
|
|
214
|
+
const settingsPath = join(loopClaudeDir(loopId), "settings.json")
|
|
215
|
+
const mtime = existsSync(settingsPath) ? statSync(settingsPath).mtimeMs : 0
|
|
216
|
+
|
|
217
|
+
const cached = ensureCache.get(loopId)
|
|
218
|
+
if (cached && cached.mtime === mtime) return
|
|
219
|
+
|
|
220
|
+
const settings = await readJsonOpt<{
|
|
221
|
+
enabledPlugins?: Record<string, boolean>
|
|
222
|
+
extraKnownMarketplaces?: Record<string, { source?: any }>
|
|
223
|
+
}>(settingsPath)
|
|
224
|
+
|
|
225
|
+
const enabled = Object.entries(settings?.enabledPlugins ?? {})
|
|
226
|
+
.filter(([_, v]) => v)
|
|
227
|
+
.map(([k]) => k)
|
|
228
|
+
|
|
229
|
+
if (enabled.length === 0 && !settings?.extraKnownMarketplaces) {
|
|
230
|
+
ensureCache.set(loopId, { mtime })
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const km = await readJsonOpt<KnownMarketplacesFile>(USER_KNOWN_MARKETPLACES)
|
|
235
|
+
const ip = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS)
|
|
236
|
+
|
|
237
|
+
// Read the loop's plugin version lock (compose's merged installed_plugins.json
|
|
238
|
+
// snapshot). When present, ensurePluginsInstalled enforces the pinned
|
|
239
|
+
// version per spec; otherwise it falls back to "install if missing".
|
|
240
|
+
const lockPath = join(loopClaudeDir(loopId), "plugins", "installed_plugins.json")
|
|
241
|
+
const lock = await readJsonOpt<InstalledPluginsFile>(lockPath)
|
|
242
|
+
const lockedVersions: Record<string, string | undefined> = {}
|
|
243
|
+
for (const [spec, entries] of Object.entries(lock?.plugins ?? {})) {
|
|
244
|
+
lockedVersions[spec] = entries[0]?.version
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await ensureExtraMarketplaces(settings?.extraKnownMarketplaces, loopId, km)
|
|
248
|
+
await ensurePluginsInstalled(enabled, ip, lockedVersions)
|
|
249
|
+
|
|
250
|
+
ensureCache.set(loopId, { mtime })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Resolve a `name@marketplace` spec to a host path (best-effort, no install
|
|
255
|
+
* side-effect). Used by:
|
|
256
|
+
* - session.ts: pre-seed plugin slash-commands before CC's init payload
|
|
257
|
+
* - loop-stats.ts: count plugin contributions for the NewLoopDialog preview
|
|
258
|
+
*
|
|
259
|
+
* Prefers the marketplace's local source dir (preserves symlinks); falls back
|
|
260
|
+
* to the CC cache installPath. Returns null if not installed.
|
|
261
|
+
*/
|
|
262
|
+
export async function lookupPluginInstallPath(spec: string): Promise<string | null> {
|
|
263
|
+
const ip = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS)
|
|
264
|
+
const km = await readJsonOpt<KnownMarketplacesFile>(USER_KNOWN_MARKETPLACES)
|
|
265
|
+
if (!ip) return null
|
|
266
|
+
const entry = ip.plugins?.[spec]?.[0]
|
|
267
|
+
if (!entry?.installPath) return null
|
|
268
|
+
|
|
269
|
+
const atIdx = spec.lastIndexOf("@")
|
|
270
|
+
if (atIdx >= 0) {
|
|
271
|
+
const pluginName = spec.slice(0, atIdx)
|
|
272
|
+
const marketName = spec.slice(atIdx + 1)
|
|
273
|
+
const market = km?.[marketName]
|
|
274
|
+
if (market?.installLocation) {
|
|
275
|
+
const catalog = await readJsonOpt<MarketplaceCatalog>(
|
|
276
|
+
join(market.installLocation, ".claude-plugin", "marketplace.json"),
|
|
277
|
+
)
|
|
278
|
+
const cat = catalog?.plugins?.find((p) => p.name === pluginName)
|
|
279
|
+
const src = typeof cat?.source === "string" ? cat.source : null
|
|
280
|
+
if (src?.startsWith("./")) {
|
|
281
|
+
const p = join(market.installLocation, src)
|
|
282
|
+
if (existsSync(p)) return p
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return existsSync(entry.installPath) ? entry.installPath : null
|
|
287
|
+
}
|