ompchamber 3.5.0 → 3.6.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/client/index.html +1 -1
- package/dist/client/index.js +357 -353
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -0
- package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- package/src/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-vx0ernrv.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- package/src/server/routes/omp/commands.ts +0 -29
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Command (slash-command) file location and authoring for the chamber's
|
|
8
|
+
* Commands settings.
|
|
9
|
+
*
|
|
10
|
+
* omp discovers markdown slash commands from every provider's config dirs and
|
|
11
|
+
* `get_available_commands` reports them by name, description and argument hint
|
|
12
|
+
* — but never by path, and never with the scope it found them at. The chamber
|
|
13
|
+
* needs both to answer "is this a user-scope command or a project-scope one,
|
|
14
|
+
* and may I edit it?", so this module probes the same directories omp reads.
|
|
15
|
+
*
|
|
16
|
+
* Two levels, probed project-first (omp resolves a name present in both roots
|
|
17
|
+
* to the project file — verified: `dupe.md` in `~/.omp/agent/commands` and in a
|
|
18
|
+
* workspace's `.omp/commands` runs the project body):
|
|
19
|
+
*
|
|
20
|
+
* - project: `<workspace>/.omp/commands`, `.claude/commands`, `.codex/commands`,
|
|
21
|
+
* `.agents/commands`, `.opencode/commands`, `.github/prompts/*.prompt.md`
|
|
22
|
+
* - user: `~/.omp/agent/commands`, `~/.claude/commands`, `~/.codex/commands`,
|
|
23
|
+
* `~/.agents/commands`, `~/.config/opencode/commands`
|
|
24
|
+
*
|
|
25
|
+
* Only the two omp-native `.omp` roots are `managed`: a command from `.claude`
|
|
26
|
+
* or `.agents` is shown read-only, because the chamber is not the tool that put
|
|
27
|
+
* it there and a save or delete through this pane would rewrite another tool's
|
|
28
|
+
* file. A nested Claude command is addressed as `ns:name` by omp and stored as
|
|
29
|
+
* `<dir>/ns/name.md`, which is why a colon maps to a path separator here.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'fs';
|
|
33
|
+
import { join, resolve } from 'path';
|
|
34
|
+
import { getAgentDir, pathExists } from '@/server/lib/omp/core/paths';
|
|
35
|
+
import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
|
|
36
|
+
import { parseFrontmatter } from '@/server/lib/omp/config/yaml';
|
|
37
|
+
|
|
38
|
+
const MAX_COMMAND_MD_BYTES = 512 * 1024;
|
|
39
|
+
|
|
40
|
+
/** Project-level command dirs, relative to a workspace root. `github` holds
|
|
41
|
+
* VS Code Copilot prompt files, whose names carry the `.prompt` suffix. */
|
|
42
|
+
const PROJECT_COMMAND_DIRS: Array<{ dir: string; suffix: string }> = [
|
|
43
|
+
{ dir: '.omp/commands', suffix: '.md' },
|
|
44
|
+
{ dir: '.claude/commands', suffix: '.md' },
|
|
45
|
+
{ dir: '.codex/commands', suffix: '.md' },
|
|
46
|
+
{ dir: '.agents/commands', suffix: '.md' },
|
|
47
|
+
{ dir: '.opencode/commands', suffix: '.md' },
|
|
48
|
+
{ dir: '.github/prompts', suffix: '.prompt.md' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** User-level command dirs. `.omp/agent` comes from the agent dir resolution
|
|
52
|
+
* so `PI_CODING_AGENT_DIR` and profiles are honored. */
|
|
53
|
+
function userCommandDirs(): Array<{ dir: string; suffix: string }> {
|
|
54
|
+
return [
|
|
55
|
+
{ dir: join(getAgentDir(), 'commands'), suffix: '.md' },
|
|
56
|
+
{ dir: join(Bun.env.HOME ?? '', '.claude', 'commands'), suffix: '.md' },
|
|
57
|
+
{ dir: join(Bun.env.HOME ?? '', '.codex', 'commands'), suffix: '.md' },
|
|
58
|
+
{ dir: join(Bun.env.HOME ?? '', '.agents', 'commands'), suffix: '.md' },
|
|
59
|
+
{ dir: join(Bun.env.HOME ?? '', '.config', 'opencode', 'commands'), suffix: '.md' },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CommandFileLocation {
|
|
64
|
+
filePath: string;
|
|
65
|
+
scope: 'user' | 'project';
|
|
66
|
+
/** True for the two omp-native roots the chamber may write and delete in. */
|
|
67
|
+
managed: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A command name that is safe as a relative path inside a command dir. */
|
|
71
|
+
function safeRelativeName(name: string): string | null {
|
|
72
|
+
const trimmed = name.trim().replace(/^\/+/, '');
|
|
73
|
+
if (!trimmed) return null;
|
|
74
|
+
if (/[\\/]/.test(trimmed.replace(/:/g, '/')) || trimmed === '.' || trimmed === '..') return null;
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The file backing a command, or null when it is not a markdown command file
|
|
79
|
+
* (a builtin, a skill, an extension command) or was not found in any root. */
|
|
80
|
+
export async function locateCommandFile(
|
|
81
|
+
name: string,
|
|
82
|
+
projectDir?: string | null,
|
|
83
|
+
): Promise<CommandFileLocation | null> {
|
|
84
|
+
const relative = safeRelativeName(name);
|
|
85
|
+
if (!relative) return null;
|
|
86
|
+
const asPath = relative.replace(/:/g, '/');
|
|
87
|
+
|
|
88
|
+
const candidates: Array<{ dir: string; suffix: string; scope: 'user' | 'project'; managed: boolean }> = [];
|
|
89
|
+
if (projectDir) {
|
|
90
|
+
for (const entry of PROJECT_COMMAND_DIRS) {
|
|
91
|
+
candidates.push({ ...entry, dir: join(projectDir, entry.dir), scope: 'project', managed: entry.dir === '.omp/commands' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const entry of userCommandDirs()) {
|
|
95
|
+
candidates.push({ ...entry, scope: 'user', managed: entry.dir === join(getAgentDir(), 'commands') });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const candidate of candidates) {
|
|
99
|
+
const filePath = join(candidate.dir, `${asPath}${candidate.suffix}`);
|
|
100
|
+
if (await pathExists(filePath)) {
|
|
101
|
+
return { filePath, scope: candidate.scope, managed: candidate.managed };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read a command markdown file, split into frontmatter fields and template. */
|
|
108
|
+
export async function readCommandFile(
|
|
109
|
+
filePath: string,
|
|
110
|
+
): Promise<{ description: string; argumentHint: string; body: string } | null> {
|
|
111
|
+
try {
|
|
112
|
+
const file = Bun.file(filePath);
|
|
113
|
+
if (!(await file.exists()) || (await file.stat()).size > MAX_COMMAND_MD_BYTES) return null;
|
|
114
|
+
const { data, body } = parseFrontmatter(await file.text());
|
|
115
|
+
return {
|
|
116
|
+
description: data.description ?? '',
|
|
117
|
+
argumentHint: data['argument-hint'] ?? data.argumentHint ?? '',
|
|
118
|
+
body: body.replace(/^\r?\n/, ''),
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Serialize a command markdown file. omp reads `description` and
|
|
126
|
+
* `argument-hint` from frontmatter; the body is the prompt template. */
|
|
127
|
+
function serializeCommandMd(input: { description: string; argumentHint: string; body: string }): string {
|
|
128
|
+
const lines = ['---'];
|
|
129
|
+
if (input.description) lines.push(`description: ${JSON.stringify(input.description)}`);
|
|
130
|
+
if (input.argumentHint) lines.push(`argument-hint: ${JSON.stringify(input.argumentHint)}`);
|
|
131
|
+
lines.push('---', '', input.body.replace(/\r\n/g, '\n').replace(/\s*$/, ''), '');
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface WriteCommandInput {
|
|
136
|
+
/** Target root: `user` writes to the agent dir, `project` to the workspace. */
|
|
137
|
+
scope: 'user' | 'project';
|
|
138
|
+
/** Workspace root, required for `project`. */
|
|
139
|
+
projectDir?: string | null;
|
|
140
|
+
/** Name of the command being edited, when it is being renamed. */
|
|
141
|
+
previousName?: string | null;
|
|
142
|
+
name: string;
|
|
143
|
+
description: string;
|
|
144
|
+
argumentHint: string;
|
|
145
|
+
body: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Create or overwrite a chamber-managed command file. Refuses a project write
|
|
149
|
+
* with no workspace rather than landing it somewhere the user did not pick. */
|
|
150
|
+
export async function writeCommandFile(input: WriteCommandInput): Promise<{ filePath: string }> {
|
|
151
|
+
const relative = safeRelativeName(input.name);
|
|
152
|
+
if (!relative) throw new Error('Command name is required');
|
|
153
|
+
if (input.scope === 'project' && !input.projectDir) {
|
|
154
|
+
throw new Error('A workspace root is required to write a project command');
|
|
155
|
+
}
|
|
156
|
+
const root = input.scope === 'project'
|
|
157
|
+
? join(input.projectDir!, '.omp', 'commands')
|
|
158
|
+
: join(getAgentDir(), 'commands');
|
|
159
|
+
const filePath = join(root, `${relative.replace(/:/g, '/')}.md`);
|
|
160
|
+
|
|
161
|
+
// A rename is a move: the previous file must not linger as a second command.
|
|
162
|
+
// It is removed only when it is a DIFFERENT managed file, so an edit in place
|
|
163
|
+
// never deletes the file just written.
|
|
164
|
+
const previous = input.previousName?.trim().replace(/^\/+/, '');
|
|
165
|
+
if (previous && previous !== relative) {
|
|
166
|
+
const stale = await locateCommandFile(previous, input.projectDir);
|
|
167
|
+
if (stale?.managed && resolve(stale.filePath) !== resolve(filePath)) {
|
|
168
|
+
await fs.promises.rm(stale.filePath, { force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await fs.promises.mkdir(resolve(filePath, '..'), { recursive: true });
|
|
173
|
+
await writeFileAtomic(
|
|
174
|
+
filePath,
|
|
175
|
+
serializeCommandMd({
|
|
176
|
+
description: input.description,
|
|
177
|
+
argumentHint: input.argumentHint,
|
|
178
|
+
body: input.body,
|
|
179
|
+
}),
|
|
180
|
+
{ createMode: 0o644 },
|
|
181
|
+
);
|
|
182
|
+
return { filePath };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Delete a chamber-managed command file. Refused unless the name resolves to
|
|
186
|
+
* one of the managed roots, so a delete can never reach another tool's file. */
|
|
187
|
+
export async function deleteCommandFile(name: string, projectDir?: string | null): Promise<boolean> {
|
|
188
|
+
const found = await locateCommandFile(name, projectDir);
|
|
189
|
+
if (!found) throw new Error(`"${name}" is not a command file the chamber can delete`);
|
|
190
|
+
if (!found.managed) {
|
|
191
|
+
throw new Error(`"${name}" is provided by ${found.filePath}; the chamber cannot delete it`);
|
|
192
|
+
}
|
|
193
|
+
await fs.promises.rm(found.filePath, { force: true });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The scope a skills/commands read is answered for.
|
|
8
|
+
*
|
|
9
|
+
* The rules worth locking are the fallbacks, because each one silently answers
|
|
10
|
+
* a DIFFERENT inventory rather than failing: a blank root must keep the
|
|
11
|
+
* historical app-root meaning (the composer's request for a session with no
|
|
12
|
+
* workspace folder), `scope=user` must win over a root a stale client still
|
|
13
|
+
* sends, and an unreadable root must fall back to the user scope rather than a
|
|
14
|
+
* directory the caller never asked about.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { resolveDiscoveryScope } from '@/server/lib/omp/config/scope';
|
|
20
|
+
import { getAgentDir } from '@/server/lib/omp/core/paths';
|
|
21
|
+
|
|
22
|
+
const APP_ROOT = process.cwd();
|
|
23
|
+
|
|
24
|
+
describe('resolveDiscoveryScope', () => {
|
|
25
|
+
test('scope=user reads the agent dir with no writable workspace', async () => {
|
|
26
|
+
expect(await resolveDiscoveryScope(null, 'user')).toEqual({ workspace: null, cwd: getAgentDir() });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('scope=user wins over a root, so a stale client cannot resurrect one', async () => {
|
|
30
|
+
expect(await resolveDiscoveryScope(APP_ROOT, 'user')).toEqual({ workspace: null, cwd: getAgentDir() });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('no root keeps the app root as an unscoped workspace', async () => {
|
|
34
|
+
expect(await resolveDiscoveryScope(null)).toEqual({ workspace: APP_ROOT, cwd: APP_ROOT });
|
|
35
|
+
expect(await resolveDiscoveryScope(' ')).toEqual({ workspace: APP_ROOT, cwd: APP_ROOT });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a real directory under the app root is the workspace it names', async () => {
|
|
39
|
+
const sub = `${APP_ROOT}/src`;
|
|
40
|
+
expect(await resolveDiscoveryScope(sub)).toEqual({ workspace: sub, cwd: sub });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an unreadable root falls back to the user scope', async () => {
|
|
44
|
+
expect(await resolveDiscoveryScope(`${APP_ROOT}/__no_such_dir__`)).toEqual({
|
|
45
|
+
workspace: null,
|
|
46
|
+
cwd: getAgentDir(),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The discovery scope a skills/commands read is answered for.
|
|
8
|
+
*
|
|
9
|
+
* omp resolves project-scope skills and commands from the process cwd: a child
|
|
10
|
+
* started in a workspace reports that workspace's `.omp/skills`,
|
|
11
|
+
* `.omp/commands`, `.claude/commands`, `.agents/*` and `.github/*` entries,
|
|
12
|
+
* while a child started elsewhere reports only the user-level ones. So the
|
|
13
|
+
* scope is never a filter applied after the fact — it is which directory omp
|
|
14
|
+
* is asked about, and which roots a write may land in.
|
|
15
|
+
*
|
|
16
|
+
* Verified against omp 18.4.0:
|
|
17
|
+
* - `omp skill list <repo>` reports `native:project` entries (`pr-review`,
|
|
18
|
+
* `issue-intake` in this repository) that `omp skill list ~/.omp/agent` does
|
|
19
|
+
* not, and both report the `native:user`/`agents:user` ones.
|
|
20
|
+
* - `get_available_commands` in a workspace reports its `.omp/commands` files
|
|
21
|
+
* (`projprobe`) plus the user-level ones (`userprobe`, `~/.claude/commands`),
|
|
22
|
+
* while a child in `~/.omp/agent` reports only the latter — and a child in
|
|
23
|
+
* `$HOME` reports neither the workspace's nor `~/.omp/agent/commands`, which
|
|
24
|
+
* is why the USER scope has a cwd of its own instead of "no cwd".
|
|
25
|
+
* - A duplicate name in both roots resolves to the PROJECT file (measured:
|
|
26
|
+
* `dupe.md` in `~/.omp/agent/commands` and the workspace ran the project
|
|
27
|
+
* body), which is why a managed-file lookup checks the workspace first.
|
|
28
|
+
*
|
|
29
|
+
* `resolveRoot` gates a client-supplied path (the app root, a path beneath it,
|
|
30
|
+
* or an exact registered `project_path`). The user scope bypasses it because
|
|
31
|
+
* its cwd is server-derived, never client-supplied, and an unresolvable root
|
|
32
|
+
* falls back to the user scope rather than answering with a directory the
|
|
33
|
+
* caller never asked about.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { getAgentDir } from '@/server/lib/omp/core/paths';
|
|
37
|
+
import { resolveRoot } from '@/server/lib/fs/root';
|
|
38
|
+
|
|
39
|
+
export interface DiscoveryScope {
|
|
40
|
+
/** Workspace root a write/delete may target, or null for the user scope. */
|
|
41
|
+
workspace: string | null;
|
|
42
|
+
/** The cwd omp is asked about: the workspace, the agent dir (user scope),
|
|
43
|
+
* or the app root for an unscoped read. */
|
|
44
|
+
cwd: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the `?root=` / `?scope=` a skills/commands request carries.
|
|
49
|
+
*
|
|
50
|
+
* - `scope=user` → the user scope: the agent dir as cwd, no workspace.
|
|
51
|
+
* - `root=<path>` → that workspace (gated by `resolveRoot`).
|
|
52
|
+
* - neither → the app root, which is what an unscoped request has always meant
|
|
53
|
+
* (the composer's fallback for a session with no workspace folder, where omp
|
|
54
|
+
* itself spawns in the server's cwd). It stays a workspace because the app
|
|
55
|
+
* root is a registered root the chamber may write in.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveDiscoveryScope(
|
|
58
|
+
rawRoot: string | null | undefined,
|
|
59
|
+
rawScope?: string | null,
|
|
60
|
+
): Promise<DiscoveryScope> {
|
|
61
|
+
if (rawScope === 'user') return { workspace: null, cwd: getAgentDir() };
|
|
62
|
+
if (!rawRoot || !rawRoot.trim()) return { workspace: process.cwd(), cwd: process.cwd() };
|
|
63
|
+
const resolved = await resolveRoot(rawRoot, '');
|
|
64
|
+
return resolved ? { workspace: resolved, cwd: resolved } : { workspace: null, cwd: getAgentDir() };
|
|
65
|
+
}
|
|
@@ -77,11 +77,43 @@ async function runBunxSkills(args: string[]): Promise<string> {
|
|
|
77
77
|
return stdout;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Install a catalog skill into the omp agent skill root.
|
|
82
|
+
*
|
|
83
|
+
* `--skill` is what keeps the install scoped to the entry the user clicked:
|
|
84
|
+
* the catalog lists individual skills, and a repository can hold twenty of
|
|
85
|
+
* them, so an unscoped `skills add <repo>` installs every skill in the repo
|
|
86
|
+
* (measured: 20 directories landed for one "Install" on a single-skill row).
|
|
87
|
+
* `--agent universal` writes to `~/.agents/skills`, which is the root omp's
|
|
88
|
+
* `agents` provider reads.
|
|
89
|
+
*/
|
|
90
|
+
export async function installCatalogSkill(pkg: string, skillName?: string | null): Promise<{ output: string }> {
|
|
82
91
|
if (!/^[\w.\-]+\/[\w.\-@:]+$/.test(pkg)) throw new Error('Invalid skill package');
|
|
83
92
|
if (isMockMode()) throw new Error('Skill install is unavailable in mock mode');
|
|
84
|
-
const
|
|
93
|
+
const name = skillName?.trim();
|
|
94
|
+
if (name && !/^[\w.\-]+$/.test(name)) throw new Error('Invalid skill name');
|
|
95
|
+
const output = await runBunxSkills([
|
|
96
|
+
'add',
|
|
97
|
+
pkg,
|
|
98
|
+
'--agent',
|
|
99
|
+
'universal',
|
|
100
|
+
...(name ? ['--skill', name] : []),
|
|
101
|
+
'-g',
|
|
102
|
+
'-y',
|
|
103
|
+
]);
|
|
104
|
+
return { output: output.slice(-4000) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Uninstall a catalog skill by name. `skills remove` is the counterpart of the
|
|
109
|
+
* `skills add` above and clears the symlink/copy the install created, which is
|
|
110
|
+
* what a catalog "Uninstall" has to undo — deleting the entry from the chamber's
|
|
111
|
+
* own store would leave the skill running in omp.
|
|
112
|
+
*/
|
|
113
|
+
export async function uninstallCatalogSkill(name: string): Promise<{ output: string }> {
|
|
114
|
+
if (!/^[\w.\-@/]+$/.test(name)) throw new Error('Invalid skill name');
|
|
115
|
+
if (isMockMode()) throw new Error('Skill uninstall is unavailable in mock mode');
|
|
116
|
+
const output = await runBunxSkills(['remove', name, '-g', '-y']);
|
|
85
117
|
return { output: output.slice(-4000) };
|
|
86
118
|
}
|
|
87
119
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Chamber-managed SKILL.md authoring: which root a scope writes into, what a
|
|
8
|
+
* move between roots leaves behind, and what a save does to the file body.
|
|
9
|
+
*
|
|
10
|
+
* The move is the case worth locking: a rename or a user⇄project move once left
|
|
11
|
+
* the source directory in place, so one skill existed twice and omp could load
|
|
12
|
+
* whichever it walked into first. A save is the other: the body is read back
|
|
13
|
+
* with the frontmatter's blank separator removed and written with trailing
|
|
14
|
+
* whitespace trimmed, so reading and re-saving must be a no-op.
|
|
15
|
+
*
|
|
16
|
+
* `PI_CODING_AGENT_DIR` is redirected to a temp dir, so the real
|
|
17
|
+
* `~/.omp/agent/skills` is never written.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import { tmpdir } from 'os';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
|
|
25
|
+
import { readSkillFile, writeSkillFile } from '@/server/lib/omp/config/skills';
|
|
26
|
+
|
|
27
|
+
let projectDir = '';
|
|
28
|
+
let agentDir = '';
|
|
29
|
+
let realAgentDir: string | undefined;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
projectDir = fs.mkdtempSync(join(tmpdir(), 'ompchamber-skill-project-'));
|
|
33
|
+
agentDir = fs.mkdtempSync(join(tmpdir(), 'ompchamber-skill-agent-'));
|
|
34
|
+
realAgentDir = Bun.env.PI_CODING_AGENT_DIR;
|
|
35
|
+
Bun.env.PI_CODING_AGENT_DIR = agentDir;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
if (realAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
|
|
40
|
+
else Bun.env.PI_CODING_AGENT_DIR = realAgentDir;
|
|
41
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
42
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const body = `---
|
|
46
|
+
description: "a skill"
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
do the thing
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
describe('writeSkillFile', () => {
|
|
53
|
+
test('a project write lands under the workspace .omp/skills', async () => {
|
|
54
|
+
const { filePath } = await writeSkillFile({
|
|
55
|
+
scope: 'project',
|
|
56
|
+
projectDir,
|
|
57
|
+
name: 'proj-skill',
|
|
58
|
+
description: 'a skill',
|
|
59
|
+
hidden: false,
|
|
60
|
+
body,
|
|
61
|
+
});
|
|
62
|
+
expect(filePath).toBe(join(projectDir, '.omp', 'skills', 'proj-skill', 'SKILL.md'));
|
|
63
|
+
expect(await readSkillFile(filePath)).toEqual({ description: 'a skill', body: body, hidden: false });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('re-saving what was read does not grow the file', async () => {
|
|
67
|
+
const filePath = join(projectDir, '.omp', 'skills', 'proj-skill', 'SKILL.md');
|
|
68
|
+
const first = fs.readFileSync(filePath, 'utf8');
|
|
69
|
+
const parsed = await readSkillFile(filePath);
|
|
70
|
+
await writeSkillFile({
|
|
71
|
+
scope: 'project',
|
|
72
|
+
projectDir,
|
|
73
|
+
name: 'proj-skill',
|
|
74
|
+
description: parsed!.description,
|
|
75
|
+
hidden: parsed!.hidden,
|
|
76
|
+
body: parsed!.body,
|
|
77
|
+
});
|
|
78
|
+
expect(fs.readFileSync(filePath, 'utf8')).toBe(first);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a move between scopes removes the source, leaving one skill', async () => {
|
|
82
|
+
const moved = await writeSkillFile({
|
|
83
|
+
scope: 'user',
|
|
84
|
+
projectDir,
|
|
85
|
+
previousDir: join(projectDir, '.omp', 'skills', 'proj-skill'),
|
|
86
|
+
name: 'proj-skill',
|
|
87
|
+
description: 'a skill',
|
|
88
|
+
hidden: false,
|
|
89
|
+
body,
|
|
90
|
+
});
|
|
91
|
+
expect(moved.filePath).toBe(join(agentDir, 'skills', 'proj-skill', 'SKILL.md'));
|
|
92
|
+
expect(fs.existsSync(join(projectDir, '.omp', 'skills', 'proj-skill'))).toBe(false);
|
|
93
|
+
expect(fs.existsSync(moved.filePath)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a project write with no workspace is refused', () => {
|
|
97
|
+
expect(writeSkillFile({ scope: 'project', name: 'lost', description: 'd', hidden: false, body: 'b' }))
|
|
98
|
+
.rejects.toThrow('workspace root is required to write a project skill');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a name that is not a single directory is refused', () => {
|
|
102
|
+
expect(writeSkillFile({ scope: 'project', projectDir, name: '../escape', description: 'd', hidden: false, body: 'b' }))
|
|
103
|
+
.rejects.toThrow('path separator');
|
|
104
|
+
});
|
|
105
|
+
});
|