@baize-ai/core 0.2.0 → 0.3.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/.dockerignore +8 -18
- package/CHANGELOG.md +19 -1
- package/Dockerfile +66 -12
- package/README.md +85 -6
- package/README.zh-CN.md +63 -3
- package/assets/logo.png +0 -0
- package/cli/baize.js +6 -0
- package/cli/commands/a2a.js +124 -0
- package/cli/commands/add.js +126 -86
- package/cli/commands/component.js +27 -4
- package/cli/commands/doctor.js +2 -2
- package/cli/commands/init.js +28 -0
- package/cli/commands/self-uninstall.js +1 -1
- package/cli/lib/__tests__/instruction-split.test.js +1 -1
- package/cli/lib/a2a.js +235 -0
- package/cli/lib/lock.js +3 -3
- package/cli/lib/self-upgrade.js +1 -1
- package/docker-compose.yml +1 -1
- package/docs/docker.md +18 -4
- package/docs/release.md +150 -0
- package/package.json +1 -1
- package/scripts/docker-publish.sh +70 -0
- package/scripts/install.sh +4 -4
- package/scripts/pack-release.sh +361 -0
- package/skills/comm-bridge/package.json +7 -3
- package/skills/comm-bridge/scripts/c4-receive.js +23 -2
- package/skills/scheduler/package.json +2 -2
- package/skills/web-console/SKILL.md +34 -0
- package/skills/web-console/package.json +2 -2
- package/skills/web-console/public/app.js +900 -4
- package/skills/web-console/public/index.html +126 -0
- package/skills/web-console/public/styles.css +130 -0
- package/skills/web-console/scripts/a2a-admin.js +506 -0
- package/skills/web-console/scripts/channel-admin.js +99 -16
- package/skills/web-console/scripts/server.js +386 -1
- package/skills/web-console/scripts/skill-catalog.js +179 -0
- package/templates/claude-system.md +22 -0
- package/templates/pm2/ecosystem.config.cjs +4 -1
- package/test/a2a-cli.test.js +180 -0
- package/test/agent-card-api.test.js +261 -0
- package/test/channel-admin.test.js +87 -0
- package/test/component-lock.test.js +216 -0
- package/test/skill-catalog.test.js +118 -0
- package/test/web-console-routes.test.js +488 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component lock coverage (D20 unit A):
|
|
3
|
+
* - lock.js error copy is operation-generic (add/upgrade/uninstall)
|
|
4
|
+
* - baize add holds the component lock for install writes; --check preview
|
|
5
|
+
* never acquires it; lock failures reject with a clear error
|
|
6
|
+
* - baize uninstall holds the component lock for removal writes
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
|
|
12
|
+
|
|
13
|
+
let homeDir;
|
|
14
|
+
let baizeDir;
|
|
15
|
+
let locksDir;
|
|
16
|
+
let skillsDir;
|
|
17
|
+
let componentsFile;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.resetModules(); // config constants (BAIZE_DIR, LOCKS_DIR, ...) are read at module load
|
|
21
|
+
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'comp-lock-'));
|
|
22
|
+
baizeDir = path.join(homeDir, 'baize');
|
|
23
|
+
locksDir = path.join(baizeDir, '.baize', 'locks');
|
|
24
|
+
skillsDir = path.join(baizeDir, '.claude', 'skills');
|
|
25
|
+
componentsFile = path.join(baizeDir, '.baize', 'components.json');
|
|
26
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
27
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
28
|
+
process.env.HOME = homeDir;
|
|
29
|
+
process.env.BAIZE_DIR = baizeDir;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
delete process.env.HOME;
|
|
34
|
+
delete process.env.BAIZE_DIR;
|
|
35
|
+
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Minimal declarative component served from a local directory: resolves
|
|
40
|
+
* without network and installs without npm/hooks/services.
|
|
41
|
+
*/
|
|
42
|
+
function writeLocalComponent(srcRoot, name) {
|
|
43
|
+
const dir = path.join(srcRoot, name);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\nversion: 1.0.0\n---\n# ${name}\n`, 'utf8');
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeInstalledComponent(name) {
|
|
50
|
+
const skillDir = path.join(skillsDir, name);
|
|
51
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\nname: ${name}\nversion: 1.0.0\n---\n`, 'utf8');
|
|
53
|
+
fs.writeFileSync(componentsFile, JSON.stringify({
|
|
54
|
+
[name]: { version: '1.0.0', repo: null, skillDir, source: { type: 'local-dir', path: skillDir } },
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readComponents() {
|
|
59
|
+
return JSON.parse(fs.readFileSync(componentsFile, 'utf8'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Replace process.exit with a throw so control flow stops like a real exit
|
|
64
|
+
* (jest itself must not be terminated). Restore with mockRestore().
|
|
65
|
+
*/
|
|
66
|
+
function mockExit() {
|
|
67
|
+
return jest.spyOn(process, 'exit').mockImplementation((code) => {
|
|
68
|
+
throw new Error(`process.exit(${code})`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('lock.js error copy', () => {
|
|
73
|
+
test('valid-lock error is operation-generic (add/upgrade/uninstall)', async () => {
|
|
74
|
+
const { acquireLock, releaseLock } = await import('../cli/lib/lock.js');
|
|
75
|
+
|
|
76
|
+
expect(acquireLock('genmsg').success).toBe(true);
|
|
77
|
+
const second = acquireLock('genmsg');
|
|
78
|
+
expect(second.success).toBe(false);
|
|
79
|
+
expect(second.error).toContain('is being operated on by PID');
|
|
80
|
+
expect(second.error).not.toContain('is being upgraded by');
|
|
81
|
+
|
|
82
|
+
expect(releaseLock('genmsg').success).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('baize add lock', () => {
|
|
87
|
+
test('rejects with a clear error when the component is already locked', async () => {
|
|
88
|
+
const { acquireLock, releaseLock } = await import('../cli/lib/lock.js');
|
|
89
|
+
const { addComponent } = await import('../cli/commands/add.js');
|
|
90
|
+
const srcDir = writeLocalComponent(path.join(homeDir, 'src'), 'lockedcomp');
|
|
91
|
+
|
|
92
|
+
// Pre-existing valid lock held by this (live) process
|
|
93
|
+
expect(acquireLock('lockedcomp').success).toBe(true);
|
|
94
|
+
|
|
95
|
+
const exitSpy = mockExit();
|
|
96
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
97
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
98
|
+
let output = '';
|
|
99
|
+
try {
|
|
100
|
+
await expect(addComponent([srcDir, '--yes'])).rejects.toThrow('process.exit(1)');
|
|
101
|
+
output = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
102
|
+
} finally {
|
|
103
|
+
exitSpy.mockRestore();
|
|
104
|
+
errorSpy.mockRestore();
|
|
105
|
+
logSpy.mockRestore();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(output).toContain('is being operated on by PID');
|
|
109
|
+
// Nothing was installed and the pre-existing lock is untouched
|
|
110
|
+
expect(fs.existsSync(path.join(skillsDir, 'lockedcomp'))).toBe(false);
|
|
111
|
+
expect(fs.existsSync(path.join(locksDir, 'lockedcomp.lock'))).toBe(true);
|
|
112
|
+
|
|
113
|
+
releaseLock('lockedcomp');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('installs successfully after the lock is released, and releases it afterwards', async () => {
|
|
117
|
+
const { acquireLock, releaseLock } = await import('../cli/lib/lock.js');
|
|
118
|
+
const { addComponent } = await import('../cli/commands/add.js');
|
|
119
|
+
const srcDir = writeLocalComponent(path.join(homeDir, 'src'), 'goodcomp');
|
|
120
|
+
|
|
121
|
+
expect(acquireLock('goodcomp').success).toBe(true);
|
|
122
|
+
expect(releaseLock('goodcomp').success).toBe(true);
|
|
123
|
+
|
|
124
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
125
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
126
|
+
try {
|
|
127
|
+
await addComponent([srcDir, '--yes']);
|
|
128
|
+
} finally {
|
|
129
|
+
logSpy.mockRestore();
|
|
130
|
+
errorSpy.mockRestore();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Installed and the lock was released by the finally block
|
|
134
|
+
expect(readComponents().goodcomp).toBeTruthy();
|
|
135
|
+
expect(fs.existsSync(path.join(skillsDir, 'goodcomp', 'SKILL.md'))).toBe(true);
|
|
136
|
+
expect(fs.existsSync(path.join(locksDir, 'goodcomp.lock'))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('--check preview mode never acquires the component lock', async () => {
|
|
140
|
+
const { acquireLock, releaseLock } = await import('../cli/lib/lock.js');
|
|
141
|
+
const { addComponent } = await import('../cli/commands/add.js');
|
|
142
|
+
const srcDir = writeLocalComponent(path.join(homeDir, 'src'), 'checkcomp');
|
|
143
|
+
|
|
144
|
+
// Even with a valid lock already held, --check must succeed: preview is read-only
|
|
145
|
+
expect(acquireLock('checkcomp').success).toBe(true);
|
|
146
|
+
|
|
147
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
148
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
149
|
+
const exitSpy = mockExit();
|
|
150
|
+
try {
|
|
151
|
+
const result = await addComponent([srcDir, '--check']);
|
|
152
|
+
expect(result).toBeUndefined(); // returned normally, no exit
|
|
153
|
+
} finally {
|
|
154
|
+
logSpy.mockRestore();
|
|
155
|
+
errorSpy.mockRestore();
|
|
156
|
+
exitSpy.mockRestore();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Lock is still held by us: --check never acquired/released it
|
|
160
|
+
expect(fs.existsSync(path.join(locksDir, 'checkcomp.lock'))).toBe(true);
|
|
161
|
+
// And nothing was installed
|
|
162
|
+
expect(fs.existsSync(path.join(skillsDir, 'checkcomp'))).toBe(false);
|
|
163
|
+
|
|
164
|
+
releaseLock('checkcomp');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('baize uninstall lock', () => {
|
|
169
|
+
test('rejects with a clear error when the component is already locked', async () => {
|
|
170
|
+
const { acquireLock } = await import('../cli/lib/lock.js');
|
|
171
|
+
const { uninstallComponent } = await import('../cli/commands/component.js');
|
|
172
|
+
writeInstalledComponent('lockedun');
|
|
173
|
+
|
|
174
|
+
expect(acquireLock('lockedun').success).toBe(true);
|
|
175
|
+
|
|
176
|
+
const exitSpy = mockExit();
|
|
177
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
178
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
179
|
+
let output = '';
|
|
180
|
+
try {
|
|
181
|
+
await expect(uninstallComponent(['lockedun', '--yes'])).rejects.toThrow('process.exit(1)');
|
|
182
|
+
output = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
183
|
+
} finally {
|
|
184
|
+
exitSpy.mockRestore();
|
|
185
|
+
errorSpy.mockRestore();
|
|
186
|
+
logSpy.mockRestore();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
expect(output).toContain('is being operated on by PID');
|
|
190
|
+
// Nothing was removed
|
|
191
|
+
expect(readComponents().lockedun).toBeTruthy();
|
|
192
|
+
expect(fs.existsSync(path.join(skillsDir, 'lockedun'))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('uninstalls successfully and releases the lock afterwards', async () => {
|
|
196
|
+
const { uninstallComponent } = await import('../cli/commands/component.js');
|
|
197
|
+
writeInstalledComponent('goodun');
|
|
198
|
+
|
|
199
|
+
const exitSpy = mockExit();
|
|
200
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
201
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
202
|
+
try {
|
|
203
|
+
const result = await uninstallComponent(['goodun', '--yes']);
|
|
204
|
+
expect(result).toBeUndefined();
|
|
205
|
+
} finally {
|
|
206
|
+
exitSpy.mockRestore();
|
|
207
|
+
logSpy.mockRestore();
|
|
208
|
+
errorSpy.mockRestore();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Removed and the lock was released by the finally block
|
|
212
|
+
expect(readComponents().goodun).toBeUndefined();
|
|
213
|
+
expect(fs.existsSync(path.join(skillsDir, 'goodun'))).toBe(false);
|
|
214
|
+
expect(fs.existsSync(path.join(locksDir, 'goodun.lock'))).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
|
|
5
|
+
import {
|
|
6
|
+
defaultSkillsDir,
|
|
7
|
+
listInstalledSkills,
|
|
8
|
+
parseSkillFrontmatter,
|
|
9
|
+
} from '../skills/web-console/scripts/skill-catalog.js';
|
|
10
|
+
|
|
11
|
+
let tempRoot;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-catalog-test-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('parseSkillFrontmatter', () => {
|
|
22
|
+
test('extracts name and a plain single-line description', () => {
|
|
23
|
+
const parsed = parseSkillFrontmatter(
|
|
24
|
+
'---\nname: web-console\ndescription: Built-in web interface for Claude.\n---\n# Body\n'
|
|
25
|
+
);
|
|
26
|
+
expect(parsed).toEqual({ name: 'web-console', description: 'Built-in web interface for Claude.' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('folds a >- multi-line description into a single line', () => {
|
|
30
|
+
const parsed = parseSkillFrontmatter(
|
|
31
|
+
'---\nname: baize-memory\ndescription: >-\n Core memory system. Maintains persistent memory\n across sessions. Handles context-aware state.\n---\n'
|
|
32
|
+
);
|
|
33
|
+
expect(parsed).toEqual({
|
|
34
|
+
name: 'baize-memory',
|
|
35
|
+
description: 'Core memory system. Maintains persistent memory across sessions. Handles context-aware state.',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('keeps newlines in a |- literal multi-line description', () => {
|
|
40
|
+
const parsed = parseSkillFrontmatter(
|
|
41
|
+
'---\nname: lit\ndescription: |-\n Line one\n Line two\n---\n'
|
|
42
|
+
);
|
|
43
|
+
expect(parsed).toEqual({ name: 'lit', description: 'Line one\nLine two' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('joins plain multi-line continuation description with spaces', () => {
|
|
47
|
+
const parsed = parseSkillFrontmatter(
|
|
48
|
+
'---\nname: multi\ndescription: First part\n second part\n---\n'
|
|
49
|
+
);
|
|
50
|
+
expect(parsed).toEqual({ name: 'multi', description: 'First part second part' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('returns null when there is no frontmatter block', () => {
|
|
54
|
+
expect(parseSkillFrontmatter('# No frontmatter\ndescription: x\n')).toBeNull();
|
|
55
|
+
expect(parseSkillFrontmatter('')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns null when the name key is missing', () => {
|
|
59
|
+
expect(parseSkillFrontmatter('---\ndescription: only a description\n---\n')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('strips wrapping quotes and tolerates CRLF line endings', () => {
|
|
63
|
+
const parsed = parseSkillFrontmatter(
|
|
64
|
+
'---\r\nname: "quoted skill"\r\ndescription: \'single quoted\'\r\n---\r\n'
|
|
65
|
+
);
|
|
66
|
+
expect(parsed).toEqual({ name: 'quoted skill', description: 'single quoted' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('allows an empty description', () => {
|
|
70
|
+
const parsed = parseSkillFrontmatter('---\nname: bare\n---\n');
|
|
71
|
+
expect(parsed).toEqual({ name: 'bare', description: '' });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('listInstalledSkills', () => {
|
|
76
|
+
const writeSkill = (dir, name, description) => {
|
|
77
|
+
fs.mkdirSync(path.join(tempRoot, dir), { recursive: true });
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(tempRoot, dir, 'SKILL.md'),
|
|
80
|
+
`---\nname: ${name}\ndescription: ${description}\n---\n`
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
test('lists each SKILL.md directory with its directory name as id', () => {
|
|
85
|
+
writeSkill('doccraft', 'Doc Craft', 'Craft documentation');
|
|
86
|
+
writeSkill('memory', 'Memory', '>-\n Persistent memory\n across sessions');
|
|
87
|
+
const skills = listInstalledSkills({ skillsDir: tempRoot });
|
|
88
|
+
expect(skills).toEqual([
|
|
89
|
+
{ id: 'doccraft', name: 'Doc Craft', description: 'Craft documentation' },
|
|
90
|
+
{ id: 'memory', name: 'Memory', description: 'Persistent memory across sessions' },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('skips directories without SKILL.md and broken frontmatter', () => {
|
|
95
|
+
writeSkill('good', 'Good', 'fine');
|
|
96
|
+
fs.mkdirSync(path.join(tempRoot, 'no-skill-md'), { recursive: true });
|
|
97
|
+
fs.mkdirSync(path.join(tempRoot, 'broken'));
|
|
98
|
+
fs.writeFileSync(path.join(tempRoot, 'broken', 'SKILL.md'), '# no frontmatter\n');
|
|
99
|
+
fs.writeFileSync(path.join(tempRoot, 'plain-file'), 'not a directory');
|
|
100
|
+
const skills = listInstalledSkills({ skillsDir: tempRoot });
|
|
101
|
+
expect(skills).toEqual([{ id: 'good', name: 'Good', description: 'fine' }]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('returns [] when the skills directory does not exist', () => {
|
|
105
|
+
expect(listInstalledSkills({ skillsDir: path.join(tempRoot, 'nope') })).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('default skills dir follows BAIZE_DIR', () => {
|
|
109
|
+
const prev = process.env.BAIZE_DIR;
|
|
110
|
+
try {
|
|
111
|
+
process.env.BAIZE_DIR = tempRoot;
|
|
112
|
+
expect(defaultSkillsDir()).toBe(path.join(tempRoot, '.claude', 'skills'));
|
|
113
|
+
} finally {
|
|
114
|
+
if (prev === undefined) delete process.env.BAIZE_DIR;
|
|
115
|
+
else process.env.BAIZE_DIR = prev;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|