@chatroomcp/chatroom 0.2.2 → 0.2.5

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 (40) hide show
  1. package/README.md +5 -3
  2. package/dist/app/application.js +4 -0
  3. package/dist/infrastructure/database/mcp-tool-settings-repository.d.ts +8 -0
  4. package/dist/infrastructure/database/mcp-tool-settings-repository.js +22 -0
  5. package/dist/infrastructure/database/migrations.js +22 -0
  6. package/dist/infrastructure/database/schema.d.ts +2 -2
  7. package/dist/infrastructure/database/schema.js +7 -1
  8. package/dist/mcp/server/plugin-mcp-registrar.d.ts +3 -1
  9. package/dist/mcp/server/plugin-mcp-registrar.js +13 -2
  10. package/dist/mcp/server/tool-control.d.ts +31 -0
  11. package/dist/mcp/server/tool-control.js +71 -0
  12. package/dist/native/linux/chatroom-computer-helper +0 -0
  13. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
  14. package/dist/native/windows/chatroom-computer-helper.exe +0 -0
  15. package/dist/plugins/computer/computer-service.d.ts +2 -0
  16. package/dist/plugins/computer/computer-service.js +10 -1
  17. package/dist/plugins/plugin-manager.js +6 -1
  18. package/dist/plugins/process/infrastructure/pty-process-backend.js +4 -0
  19. package/dist/plugins/types.d.ts +2 -0
  20. package/dist/plugins/web/api-types.d.ts +1 -0
  21. package/dist/plugins/web/http/api-router.js +9 -0
  22. package/dist/plugins/web/http/computer-api-router.js +4 -3
  23. package/dist/plugins/web/http/workspace-api-router.js +36 -0
  24. package/dist/plugins/web/plugin.js +1 -1
  25. package/dist/plugins/web/runtime.d.ts +3 -1
  26. package/dist/plugins/web/runtime.js +3 -1
  27. package/dist/plugins/workspace/mcp.js +4 -1
  28. package/dist/plugins/workspace/metadata.d.ts +2 -0
  29. package/dist/plugins/workspace/metadata.js +20 -10
  30. package/dist/plugins/workspace/types.d.ts +5 -0
  31. package/dist/plugins/workspace/workspace-fs.d.ts +3 -0
  32. package/dist/plugins/workspace/workspace-fs.js +93 -4
  33. package/dist/plugins/workspace/workspace-service.d.ts +4 -0
  34. package/dist/plugins/workspace/workspace-service.js +71 -16
  35. package/dist/web/assets/index-B_OJ1D0L.js +30 -0
  36. package/dist/web/assets/index-DSCBSalV.css +1 -0
  37. package/dist/web/index.html +2 -2
  38. package/package.json +1 -1
  39. package/dist/web/assets/index-DFKEvyzR.css +0 -1
  40. package/dist/web/assets/index-vzPPspuL.js +0 -26
@@ -2,16 +2,15 @@ import path from "node:path";
2
2
  import { ChatRoomError } from "../../core/errors/chatroom-error.js";
3
3
  const SKILL_ROOTS = [".agents/skills", ".claude/skills", ".chatroom/skills"];
4
4
  const MAX_METADATA_BYTES = 64 * 1024;
5
- export async function readInstructions(fs) {
6
- try {
7
- return (await fs.read("AGENTS.md", { maxBytes: MAX_METADATA_BYTES }))
8
- .content;
9
- }
10
- catch (error) {
11
- if (error instanceof ChatRoomError && error.code === "NOT_FOUND")
12
- return null;
13
- throw error;
14
- }
5
+ const MAX_SUMMARY_BYTES = 8 * 1024;
6
+ export function readSummary(fs) {
7
+ return readOptionalText(fs, ".chatroom/summary.md", MAX_SUMMARY_BYTES);
8
+ }
9
+ export function readPresetPrompt(fs) {
10
+ return readOptionalText(fs, ".chatroom/prompt.md", MAX_METADATA_BYTES);
11
+ }
12
+ export function readInstructions(fs) {
13
+ return readOptionalText(fs, "AGENTS.md", MAX_METADATA_BYTES);
15
14
  }
16
15
  export async function readSkills(fs) {
17
16
  const paths = [];
@@ -49,6 +48,17 @@ export async function readSkills(fs) {
49
48
  }
50
49
  }));
51
50
  }
51
+ async function readOptionalText(fs, relativePath, maxBytes) {
52
+ try {
53
+ const content = (await fs.read(relativePath, { maxBytes })).content;
54
+ return content.trim() ? content : null;
55
+ }
56
+ catch (error) {
57
+ if (error instanceof ChatRoomError && error.code === "NOT_FOUND")
58
+ return null;
59
+ throw error;
60
+ }
61
+ }
52
62
  function parseSkillFrontmatter(content) {
53
63
  const lines = content
54
64
  .replace(/^\uFEFF/, "")
@@ -1,5 +1,7 @@
1
1
  export interface WorkspaceEntry {
2
2
  root: string;
3
+ name: string;
4
+ summary: string | null;
3
5
  }
4
6
  export interface WorkspaceSkill {
5
7
  name: string;
@@ -8,6 +10,9 @@ export interface WorkspaceSkill {
8
10
  }
9
11
  export interface WorkspaceInfo {
10
12
  root: string;
13
+ name: string;
14
+ summary: string | null;
15
+ presetPrompt: string | null;
11
16
  instructions: string | null;
12
17
  skills: WorkspaceSkill[];
13
18
  }
@@ -17,9 +17,12 @@ export declare class WorkspaceFs {
17
17
  bytes: number;
18
18
  truncated: boolean;
19
19
  }>;
20
+ write(relativePath: string, content: string): Promise<WorkspaceFile>;
20
21
  list(relativePath?: string, options?: {
21
22
  recursive?: boolean;
22
23
  maxEntries?: number;
23
24
  }): Promise<WorkspaceFile[]>;
25
+ private lexical;
26
+ private resolveWritable;
24
27
  private resolveReadable;
25
28
  }
@@ -1,4 +1,5 @@
1
- import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { ChatRoomError } from "../../core/errors/chatroom-error.js";
4
5
  export class WorkspaceFs {
@@ -44,6 +45,44 @@ export class WorkspaceFs {
44
45
  await handle.close();
45
46
  }
46
47
  }
48
+ async write(relativePath, content) {
49
+ if (typeof content !== "string")
50
+ throw new ChatRoomError("INVALID_INPUT", "File content must be a string");
51
+ const normalized = normalizeRelative(relativePath);
52
+ if (normalized === ".")
53
+ throw new ChatRoomError("INVALID_INPUT", "File path is required");
54
+ const target = await this.resolveWritable(normalized);
55
+ let mode = 0o644;
56
+ try {
57
+ const info = await lstat(target);
58
+ if (!info.isFile())
59
+ throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
60
+ mode = info.mode & 0o777;
61
+ }
62
+ catch (error) {
63
+ if (error instanceof ChatRoomError)
64
+ throw error;
65
+ if (error.code !== "ENOENT")
66
+ throw error;
67
+ }
68
+ const temporary = `${target}.chatroom-${randomUUID()}.tmp`;
69
+ try {
70
+ await writeFile(temporary, content, {
71
+ encoding: "utf8",
72
+ flag: "wx",
73
+ mode,
74
+ });
75
+ await rename(temporary, target);
76
+ }
77
+ finally {
78
+ await rm(temporary, { force: true }).catch(() => undefined);
79
+ }
80
+ return {
81
+ path: normalized,
82
+ type: "file",
83
+ size: Buffer.byteLength(content, "utf8"),
84
+ };
85
+ }
47
86
  async list(relativePath = ".", options = {}) {
48
87
  const start = await this.resolveReadable(relativePath);
49
88
  if (!(await stat(start)).isDirectory())
@@ -73,11 +112,61 @@ export class WorkspaceFs {
73
112
  await walk(start);
74
113
  return output;
75
114
  }
76
- async resolveReadable(relativePath) {
115
+ lexical(relativePath) {
77
116
  const normalized = normalizeRelative(relativePath);
78
- const lexical = path.resolve(this.root, ...normalized.split("/"));
79
- if (!inside(this.root, lexical))
117
+ const absolute = path.resolve(this.root, ...normalized.split("/"));
118
+ if (!inside(this.root, absolute))
80
119
  throw new ChatRoomError("FORBIDDEN", "Path escapes workspace");
120
+ return absolute;
121
+ }
122
+ async resolveWritable(relativePath) {
123
+ const target = this.lexical(relativePath);
124
+ const relative = path.relative(this.root, target);
125
+ const parts = relative.split(path.sep).filter(Boolean);
126
+ let current = this.root;
127
+ for (let index = 0; index < parts.length - 1; index++) {
128
+ current = path.join(current, parts[index]);
129
+ let info;
130
+ try {
131
+ info = await lstat(current);
132
+ }
133
+ catch (error) {
134
+ if (error.code !== "ENOENT")
135
+ throw error;
136
+ try {
137
+ await mkdir(current, { recursive: false, mode: 0o700 });
138
+ }
139
+ catch (mkdirError) {
140
+ if (mkdirError.code !== "EEXIST")
141
+ throw mkdirError;
142
+ }
143
+ info = await lstat(current);
144
+ }
145
+ if (info.isSymbolicLink())
146
+ throw new ChatRoomError("FORBIDDEN", `Writes through symlinked directories are not allowed: ${relativePath}`);
147
+ if (!info.isDirectory())
148
+ throw new ChatRoomError("INVALID_INPUT", `Parent is not a directory: ${parts[index]}`);
149
+ const canonical = await realpath(current);
150
+ if (!inside(this.root, canonical))
151
+ throw new ChatRoomError("FORBIDDEN", "Write path escapes workspace");
152
+ }
153
+ try {
154
+ const targetInfo = await lstat(target);
155
+ if (targetInfo.isSymbolicLink())
156
+ throw new ChatRoomError("FORBIDDEN", `Writes through symlinks are not allowed: ${relativePath}`);
157
+ if (!targetInfo.isFile())
158
+ throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
159
+ }
160
+ catch (error) {
161
+ if (error instanceof ChatRoomError)
162
+ throw error;
163
+ if (error.code !== "ENOENT")
164
+ throw error;
165
+ }
166
+ return target;
167
+ }
168
+ async resolveReadable(relativePath) {
169
+ const lexical = this.lexical(relativePath);
81
170
  const canonical = await realpath(lexical).catch((error) => {
82
171
  if (error.code === "ENOENT")
83
172
  throw new ChatRoomError("NOT_FOUND", `Path does not exist: ${relativePath}`);
@@ -4,8 +4,12 @@ export declare class WorkspaceService {
4
4
  private readonly allowedRoots;
5
5
  private constructor();
6
6
  static create(allowedRoots: string[]): Promise<WorkspaceService>;
7
+ roots(): string[];
7
8
  list(): Promise<WorkspaceEntry[]>;
8
9
  resolve(input: string): Promise<string>;
10
+ createProject(parentInput: string, nameInput: string): Promise<WorkspaceEntry>;
9
11
  info(input: string): Promise<WorkspaceInfo>;
10
12
  fs(input: string): Promise<WorkspaceFs>;
13
+ private isWorkspaceRoot;
14
+ private resolveAllowedRoot;
11
15
  }
@@ -1,8 +1,8 @@
1
- import { readdir, realpath, stat } from "node:fs/promises";
1
+ import { mkdir, readdir, realpath, stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { ChatRoomError } from "../../core/errors/chatroom-error.js";
5
- import { readInstructions, readSkills } from "./metadata.js";
5
+ import { readInstructions, readPresetPrompt, readSkills, readSummary, } from "./metadata.js";
6
6
  import { WorkspaceFs } from "./workspace-fs.js";
7
7
  export class WorkspaceService {
8
8
  allowedRoots;
@@ -20,6 +20,9 @@ export class WorkspaceService {
20
20
  }));
21
21
  return new WorkspaceService([...new Set(canonicalRoots)]);
22
22
  }
23
+ roots() {
24
+ return [...this.allowedRoots];
25
+ }
23
26
  async list() {
24
27
  const roots = new Set();
25
28
  for (const allowedRoot of this.allowedRoots) {
@@ -36,13 +39,20 @@ export class WorkspaceService {
36
39
  entry.name.startsWith("."))
37
40
  continue;
38
41
  const candidate = await realpath(path.join(allowedRoot, entry.name)).catch(() => null);
39
- if (candidate && inside(allowedRoot, candidate))
42
+ if (candidate && this.isWorkspaceRoot(candidate))
40
43
  roots.add(candidate);
41
44
  }
42
45
  }
43
- return [...roots]
46
+ return Promise.all([...roots]
44
47
  .sort((a, b) => a.localeCompare(b))
45
- .map((root) => ({ root }));
48
+ .map(async (root) => {
49
+ const fs = await WorkspaceFs.create(root);
50
+ return {
51
+ root,
52
+ name: path.basename(root),
53
+ summary: await readSummary(fs),
54
+ };
55
+ }));
46
56
  }
47
57
  async resolve(input) {
48
58
  if (typeof input !== "string" || !input.trim())
@@ -52,22 +62,74 @@ export class WorkspaceService {
52
62
  });
53
63
  if (!(await stat(canonical)).isDirectory())
54
64
  throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${input}`);
55
- if (!this.allowedRoots.some((root) => inside(root, canonical)))
56
- throw new ChatRoomError("FORBIDDEN", "Workspace is outside configured allowedRoots", { root: canonical });
65
+ if (!this.isWorkspaceRoot(canonical))
66
+ throw new ChatRoomError("FORBIDDEN", "Workspace must be a direct child of a configured allowed root", { root: canonical });
57
67
  return canonical;
58
68
  }
69
+ async createProject(parentInput, nameInput) {
70
+ const parent = await this.resolveAllowedRoot(parentInput);
71
+ const name = validateProjectName(nameInput);
72
+ const target = path.join(parent, name);
73
+ try {
74
+ await mkdir(target, { recursive: false });
75
+ }
76
+ catch (error) {
77
+ if (error.code === "EEXIST")
78
+ throw new ChatRoomError("CONFLICT", `Workspace already exists: ${target}`);
79
+ throw error;
80
+ }
81
+ await mkdir(path.join(target, ".chatroom"));
82
+ const root = await realpath(target);
83
+ return { root, name: path.basename(root), summary: null };
84
+ }
59
85
  async info(input) {
60
86
  const root = await this.resolve(input);
61
87
  const fs = await WorkspaceFs.create(root);
62
- const [instructions, skills] = await Promise.all([
88
+ const [summary, presetPrompt, instructions, skills] = await Promise.all([
89
+ readSummary(fs),
90
+ readPresetPrompt(fs),
63
91
  readInstructions(fs),
64
92
  readSkills(fs),
65
93
  ]);
66
- return { root, instructions, skills };
94
+ return {
95
+ root,
96
+ name: path.basename(root),
97
+ summary,
98
+ presetPrompt,
99
+ instructions,
100
+ skills,
101
+ };
67
102
  }
68
103
  async fs(input) {
69
104
  return WorkspaceFs.create(await this.resolve(input));
70
105
  }
106
+ isWorkspaceRoot(candidate) {
107
+ return this.allowedRoots.some((root) => path.dirname(candidate) === root);
108
+ }
109
+ async resolveAllowedRoot(input) {
110
+ if (typeof input !== "string" || !input.trim())
111
+ throw new ChatRoomError("INVALID_INPUT", "Allowed root is required");
112
+ const canonical = await realpath(expandHome(input)).catch(() => {
113
+ throw new ChatRoomError("NOT_FOUND", `Allowed root does not exist: ${input}`);
114
+ });
115
+ if (!this.allowedRoots.includes(canonical))
116
+ throw new ChatRoomError("FORBIDDEN", "Project can only be created directly under a configured allowed root");
117
+ return canonical;
118
+ }
119
+ }
120
+ function validateProjectName(input) {
121
+ if (typeof input !== "string")
122
+ throw new ChatRoomError("INVALID_INPUT", "Project name is required");
123
+ const name = input.trim();
124
+ if (!name ||
125
+ name === "." ||
126
+ name === ".." ||
127
+ name.startsWith(".") ||
128
+ name.includes("/") ||
129
+ name.includes("\\") ||
130
+ name.includes("\0"))
131
+ throw new ChatRoomError("INVALID_INPUT", "Invalid project name");
132
+ return name;
71
133
  }
72
134
  function expandHome(input) {
73
135
  if (input === "~")
@@ -76,10 +138,3 @@ function expandHome(input) {
76
138
  return path.join(os.homedir(), input.slice(2));
77
139
  return path.resolve(input);
78
140
  }
79
- function inside(root, candidate) {
80
- const relative = path.relative(root, candidate);
81
- return (relative === "" ||
82
- (!relative.startsWith(`..${path.sep}`) &&
83
- relative !== ".." &&
84
- !path.isAbsolute(relative)));
85
- }