@shipers-dev/multi 0.12.0 → 0.13.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/dist/index.js +69 -8
- package/package.json +7 -2
- package/src/acp-runner.ts +0 -274
- package/src/acpx-runner.ts +0 -177
- package/src/client.ts +0 -46
- package/src/detect.ts +0 -70
- package/src/index.ts +0 -1784
- package/src/materializer.ts +0 -166
- package/src/worktree.ts +0 -89
package/src/materializer.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// Materialize agents + skills onto disk so claude-code (and the agent itself
|
|
2
|
-
// via Read/Bash) can load them. Pulls /api/devices/:id/agent_bundle on demand
|
|
3
|
-
// (heartbeat revision mismatch), writes to ~/.multi/skills/<slug>/ and ~/.multi/agents/,
|
|
4
|
-
// then symlinks/copies into ~/.claude/{skills,agents} marked with .multi-managed
|
|
5
|
-
// so we never clobber a user-authored skill or agent definition.
|
|
6
|
-
|
|
7
|
-
import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync, symlinkSync, readdirSync, statSync, lstatSync } from 'fs';
|
|
8
|
-
import { join, dirname } from 'path';
|
|
9
|
-
import { apiClient } from './client';
|
|
10
|
-
|
|
11
|
-
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
|
|
12
|
-
const MULTI_DIR = join(HOME, '.multi');
|
|
13
|
-
const MULTI_SKILLS = join(MULTI_DIR, 'skills');
|
|
14
|
-
const MULTI_AGENTS = join(MULTI_DIR, 'agents');
|
|
15
|
-
const STATE_PATH = join(MULTI_DIR, 'materialized.json');
|
|
16
|
-
const CLAUDE_DIR = join(HOME, '.claude');
|
|
17
|
-
const CLAUDE_SKILLS = join(CLAUDE_DIR, 'skills');
|
|
18
|
-
const CLAUDE_AGENTS = join(CLAUDE_DIR, 'agents');
|
|
19
|
-
const MARKER = '.multi-managed';
|
|
20
|
-
|
|
21
|
-
type BundleSkill = {
|
|
22
|
-
id: string;
|
|
23
|
-
name: string;
|
|
24
|
-
version: string | null;
|
|
25
|
-
description: string | null;
|
|
26
|
-
body: string | null;
|
|
27
|
-
files: { path: string; content: string }[];
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
type BundleAgent = {
|
|
31
|
-
id: string;
|
|
32
|
-
name: string;
|
|
33
|
-
type: string;
|
|
34
|
-
prompt: string | null;
|
|
35
|
-
approval_status: string;
|
|
36
|
-
allowed_tools: string | null;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
type Bundle = {
|
|
40
|
-
revision: number;
|
|
41
|
-
agents: BundleAgent[];
|
|
42
|
-
skills: BundleSkill[];
|
|
43
|
-
links: { agent_id: string; skill_id: string }[];
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
type State = {
|
|
47
|
-
revision: number;
|
|
48
|
-
skill_slugs: string[];
|
|
49
|
-
agent_slugs: string[];
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
function slugify(s: string): string {
|
|
53
|
-
return s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'unnamed';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function loadState(): State {
|
|
57
|
-
try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); }
|
|
58
|
-
catch { return { revision: -1, skill_slugs: [], agent_slugs: [] }; }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function saveState(s: State) {
|
|
62
|
-
mkdirSync(MULTI_DIR, { recursive: true });
|
|
63
|
-
writeFileSync(STATE_PATH, JSON.stringify(s, null, 2));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function safeRmManaged(path: string) {
|
|
67
|
-
if (!existsSync(path)) return;
|
|
68
|
-
try {
|
|
69
|
-
const st = lstatSync(path);
|
|
70
|
-
if (st.isSymbolicLink()) { rmSync(path); return; }
|
|
71
|
-
if (st.isDirectory() && existsSync(join(path, MARKER))) {
|
|
72
|
-
rmSync(path, { recursive: true, force: true });
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (st.isFile() && path.endsWith('.md')) {
|
|
76
|
-
const head = readFileSync(path, 'utf8').slice(0, 200);
|
|
77
|
-
if (head.includes('multi-managed: true')) rmSync(path);
|
|
78
|
-
}
|
|
79
|
-
} catch {}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function writeManagedSkill(slug: string, skill: BundleSkill) {
|
|
83
|
-
const dir = join(MULTI_SKILLS, slug);
|
|
84
|
-
mkdirSync(dir, { recursive: true });
|
|
85
|
-
writeFileSync(join(dir, MARKER), `skill_id=${skill.id}\nrevision=${Date.now()}\n`);
|
|
86
|
-
if (skill.body) writeFileSync(join(dir, 'SKILL.md'), skill.body);
|
|
87
|
-
for (const f of skill.files || []) {
|
|
88
|
-
if (f.path.includes('..') || f.path.startsWith('/')) continue; // path traversal guard
|
|
89
|
-
if (f.path === MARKER) continue;
|
|
90
|
-
const out = join(dir, f.path);
|
|
91
|
-
mkdirSync(dirname(out), { recursive: true });
|
|
92
|
-
writeFileSync(out, f.content);
|
|
93
|
-
}
|
|
94
|
-
// Symlink into ~/.claude/skills/<slug>; replace any prior managed link/dir.
|
|
95
|
-
mkdirSync(CLAUDE_SKILLS, { recursive: true });
|
|
96
|
-
const link = join(CLAUDE_SKILLS, slug);
|
|
97
|
-
safeRmManaged(link);
|
|
98
|
-
if (!existsSync(link)) {
|
|
99
|
-
try { symlinkSync(dir, link, 'dir'); } catch {}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function writeManagedAgent(slug: string, agent: BundleAgent, skillsForAgent: string[]) {
|
|
104
|
-
if (agent.type !== 'claude-code') return; // acpx agents don't read ~/.claude/agents
|
|
105
|
-
mkdirSync(CLAUDE_AGENTS, { recursive: true });
|
|
106
|
-
const out = join(CLAUDE_AGENTS, `${slug}.md`);
|
|
107
|
-
safeRmManaged(out);
|
|
108
|
-
const tools = agent.allowed_tools ? `\ntools: ${agent.allowed_tools}` : '';
|
|
109
|
-
const skillsLine = skillsForAgent.length ? `\nskills: [${skillsForAgent.join(', ')}]` : '';
|
|
110
|
-
const fm = `---\nname: ${agent.name}\ndescription: managed by multi-agent (id=${agent.id})${tools}${skillsLine}\nmulti-managed: true\n---\n\n`;
|
|
111
|
-
writeFileSync(out, fm + (agent.prompt || ''));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export async function materializeBundle(apiUrl: string, deviceId: string, log: (m: string) => void): Promise<{ revision: number } | null> {
|
|
115
|
-
const res = await apiClient.get<Bundle>(`${apiUrl}/api/devices/${deviceId}/agent_bundle`);
|
|
116
|
-
if (!res.success || !res.data) {
|
|
117
|
-
log(`materialize: bundle fetch failed: ${res.error || 'unknown'}`);
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
const bundle = res.data;
|
|
121
|
-
const prev = loadState();
|
|
122
|
-
|
|
123
|
-
const linksByAgent = new Map<string, string[]>();
|
|
124
|
-
for (const l of bundle.links) {
|
|
125
|
-
if (!linksByAgent.has(l.agent_id)) linksByAgent.set(l.agent_id, []);
|
|
126
|
-
linksByAgent.get(l.agent_id)!.push(l.skill_id);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const newSkillSlugs: string[] = [];
|
|
130
|
-
const skillIdToSlug = new Map<string, string>();
|
|
131
|
-
for (const s of bundle.skills) {
|
|
132
|
-
const slug = slugify(s.name);
|
|
133
|
-
skillIdToSlug.set(s.id, slug);
|
|
134
|
-
writeManagedSkill(slug, s);
|
|
135
|
-
newSkillSlugs.push(slug);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const newAgentSlugs: string[] = [];
|
|
139
|
-
for (const a of bundle.agents) {
|
|
140
|
-
const slug = slugify(a.name);
|
|
141
|
-
const skillSlugs = (linksByAgent.get(a.id) || []).map(id => skillIdToSlug.get(id)).filter(Boolean) as string[];
|
|
142
|
-
writeManagedAgent(slug, a, skillSlugs);
|
|
143
|
-
newAgentSlugs.push(slug);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Prune managed entries no longer in bundle.
|
|
147
|
-
for (const old of prev.skill_slugs) {
|
|
148
|
-
if (!newSkillSlugs.includes(old)) {
|
|
149
|
-
safeRmManaged(join(MULTI_SKILLS, old));
|
|
150
|
-
safeRmManaged(join(CLAUDE_SKILLS, old));
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
for (const old of prev.agent_slugs) {
|
|
154
|
-
if (!newAgentSlugs.includes(old)) {
|
|
155
|
-
safeRmManaged(join(CLAUDE_AGENTS, `${old}.md`));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
saveState({ revision: bundle.revision, skill_slugs: newSkillSlugs, agent_slugs: newAgentSlugs });
|
|
160
|
-
log(`materialize: revision=${bundle.revision} agents=${bundle.agents.length} skills=${bundle.skills.length}`);
|
|
161
|
-
return { revision: bundle.revision };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function lastMaterializedRevision(): number {
|
|
165
|
-
return loadState().revision;
|
|
166
|
-
}
|
package/src/worktree.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
// Per-issue git worktree manager. Keeps agent work isolated so parallel runs
|
|
2
|
-
// don't clobber each other. Worktrees live at <workingDir>/.multi/worktrees/<key>
|
|
3
|
-
// on branch multi/<key>, created off the current HEAD of workingDir.
|
|
4
|
-
//
|
|
5
|
-
// No automatic teardown: branch + worktree are kept after done/fail for human
|
|
6
|
-
// review.
|
|
7
|
-
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
|
|
12
|
-
export interface Worktree {
|
|
13
|
-
path: string;
|
|
14
|
-
branch: string;
|
|
15
|
-
created: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function run(cwd: string, cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
19
|
-
return await new Promise((resolve) => {
|
|
20
|
-
const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
-
let stdout = '';
|
|
22
|
-
let stderr = '';
|
|
23
|
-
p.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
24
|
-
p.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
25
|
-
p.on('close', (code) => resolve({ code: code ?? 0, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
26
|
-
p.on('error', (e) => resolve({ code: 1, stdout: '', stderr: String(e) }));
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function isGitRepo(dir: string): Promise<boolean> {
|
|
31
|
-
if (!existsSync(dir)) return false;
|
|
32
|
-
const r = await run(dir, 'git', ['rev-parse', '--is-inside-work-tree']);
|
|
33
|
-
return r.code === 0 && r.stdout === 'true';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function branchExists(dir: string, branch: string): Promise<boolean> {
|
|
37
|
-
const r = await run(dir, 'git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
38
|
-
return r.code === 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function ensureGitignoreEntry(workingDir: string, entry: string): void {
|
|
42
|
-
const gip = join(workingDir, '.gitignore');
|
|
43
|
-
let body = '';
|
|
44
|
-
try { body = existsSync(gip) ? readFileSync(gip, 'utf8') : ''; } catch { body = ''; }
|
|
45
|
-
const lines = body.split('\n');
|
|
46
|
-
if (lines.some((l) => l.trim() === entry)) return;
|
|
47
|
-
const nextBody = (body.endsWith('\n') || body === '' ? body : body + '\n') + entry + '\n';
|
|
48
|
-
try { writeFileSync(gip, nextBody, 'utf8'); } catch {}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeKey(issueKey: string): string {
|
|
52
|
-
return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, '-');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Ensure a worktree exists for this issue. Safe to call repeatedly.
|
|
57
|
-
*
|
|
58
|
-
* - Non-git workingDir: returns { path: workingDir, created: false } (no isolation).
|
|
59
|
-
* - Git workingDir: creates or reuses <workingDir>/.multi/worktrees/<key>
|
|
60
|
-
* on branch multi/<key>. On failure returns workingDir fallback.
|
|
61
|
-
*/
|
|
62
|
-
export async function ensureWorktree(workingDir: string, issueKey: string): Promise<Worktree> {
|
|
63
|
-
if (!(await isGitRepo(workingDir))) {
|
|
64
|
-
return { path: workingDir, branch: '', created: false };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
ensureGitignoreEntry(workingDir, '.multi/');
|
|
68
|
-
|
|
69
|
-
const key = normalizeKey(issueKey);
|
|
70
|
-
const branch = `multi/${key}`;
|
|
71
|
-
const wtDir = join(workingDir, '.multi', 'worktrees');
|
|
72
|
-
const wtPath = join(wtDir, key);
|
|
73
|
-
|
|
74
|
-
if (existsSync(wtPath)) {
|
|
75
|
-
return { path: wtPath, branch, created: false };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
try { mkdirSync(wtDir, { recursive: true }); } catch {}
|
|
79
|
-
|
|
80
|
-
const exists = await branchExists(workingDir, branch);
|
|
81
|
-
const args = exists
|
|
82
|
-
? ['worktree', 'add', wtPath, branch]
|
|
83
|
-
: ['worktree', 'add', '-b', branch, wtPath, 'HEAD'];
|
|
84
|
-
const r = await run(workingDir, 'git', args);
|
|
85
|
-
if (r.code !== 0) {
|
|
86
|
-
throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
|
|
87
|
-
}
|
|
88
|
-
return { path: wtPath, branch, created: true };
|
|
89
|
-
}
|