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.
Files changed (71) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +357 -353
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  8. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  9. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  10. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  11. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  12. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  13. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  14. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  15. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  16. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  17. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  18. package/src/client/data/settings/command.ts +0 -40
  19. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  20. package/src/client/hooks/ui/updates/index.ts +178 -0
  21. package/src/server/db.server.ts +41 -11
  22. package/src/server/index.ts +17 -1
  23. package/src/server/lib/db/client.ts +17 -0
  24. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  25. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  26. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  27. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  28. package/src/server/lib/omp/config/commands.test.ts +167 -0
  29. package/src/server/lib/omp/config/commands.ts +195 -0
  30. package/src/server/lib/omp/config/scope.test.ts +49 -0
  31. package/src/server/lib/omp/config/scope.ts +65 -0
  32. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  33. package/src/server/lib/omp/config/skills.test.ts +105 -0
  34. package/src/server/lib/omp/config/skills.ts +260 -47
  35. package/src/server/lib/omp/rpc/process.ts +22 -14
  36. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  37. package/src/server/lib/omp/rpc/utility.ts +45 -23
  38. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  39. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  41. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  42. package/src/server/lib/omp/session/reload.server.ts +47 -0
  43. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  44. package/src/server/lib/terminal/liveness.server.ts +65 -0
  45. package/src/server/lib/terminal/runtime.server.ts +70 -117
  46. package/src/server/lib/terminal/session.server.ts +5 -2
  47. package/src/server/lib/terminal/store.ts +72 -0
  48. package/src/server/lib/updates/apply.ts +62 -33
  49. package/src/server/lib/updates/install.ts +9 -2
  50. package/src/server/lib/updates/omp.ts +37 -9
  51. package/src/server/lib/updates/single-flight.test.ts +61 -0
  52. package/src/server/lib/updates/single-flight.ts +68 -0
  53. package/src/server/routes/chat/session.ts +13 -3
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/server/routes/omp/index.ts +0 -2
  56. package/src/server/routes/settings/commands.ts +102 -15
  57. package/src/server/routes/settings/skills.ts +165 -113
  58. package/src/server/routes/terminal/sessions.ts +1 -1
  59. package/src/server/routes/updates/apply.ts +101 -14
  60. package/src/shared/lib/chat/composer/client.ts +27 -18
  61. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  62. package/src/shared/lib/markdown/katex.ts +11 -1
  63. package/src/shared/lib/markdown/marked.ts +68 -13
  64. package/src/shared/lib/markdown/math.test.ts +53 -1
  65. package/src/shared/lib/updates/progress.test.ts +54 -0
  66. package/src/shared/lib/updates/progress.ts +50 -0
  67. package/src/shared/types/settings/command.ts +15 -2
  68. package/src/shared/types/settings/skill.ts +13 -0
  69. package/dist/client/static/js/index-vx0ernrv.css +0 -1
  70. package/src/client/hooks/ui/updates.ts +0 -108
  71. 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
- /** Install a catalog skill into the omp agent skill root. */
81
- export async function installCatalogSkill(pkg: string): Promise<{ output: string }> {
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 output = await runBunxSkills(['add', pkg, '--agent', 'universal', '-g', '-y']);
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
+ });