@musashishao/folderforge 1.2.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/README.md +181 -0
- package/dist/adapters/child-mcp/client.js +114 -0
- package/dist/adapters/child-mcp/registry.js +66 -0
- package/dist/audit/audit-log.js +45 -0
- package/dist/audit/event-types.js +1 -0
- package/dist/core/config.js +211 -0
- package/dist/core/container.js +51 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/types.js +4 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/lsp/protocol.js +116 -0
- package/dist/main.js +190 -0
- package/dist/managers/db-manager.js +161 -0
- package/dist/managers/lsp-manager.js +269 -0
- package/dist/managers/process-manager.js +140 -0
- package/dist/policy/approvals.js +143 -0
- package/dist/policy/command-policy.js +99 -0
- package/dist/policy/glob-match.js +61 -0
- package/dist/policy/path-policy.js +73 -0
- package/dist/policy/policy-engine.js +156 -0
- package/dist/policy/rate-limiter.js +96 -0
- package/dist/policy/risk.js +112 -0
- package/dist/policy/secret-policy.js +132 -0
- package/dist/server/mcp-server.js +144 -0
- package/dist/server/transports/http.js +133 -0
- package/dist/server/transports/stdio.js +14 -0
- package/dist/tools/adapter-tools.js +62 -0
- package/dist/tools/browser-tools.js +76 -0
- package/dist/tools/build-tools.js +78 -0
- package/dist/tools/code-tools.js +250 -0
- package/dist/tools/coverage-tools.js +135 -0
- package/dist/tools/db-tools.js +130 -0
- package/dist/tools/diff-util.js +45 -0
- package/dist/tools/error-parser.js +57 -0
- package/dist/tools/file-tools.js +319 -0
- package/dist/tools/format-tools.js +118 -0
- package/dist/tools/git-tools.js +371 -0
- package/dist/tools/index.js +63 -0
- package/dist/tools/memory-tools.js +54 -0
- package/dist/tools/output-schemas.js +100 -0
- package/dist/tools/pagination.js +92 -0
- package/dist/tools/pkg-tools.js +260 -0
- package/dist/tools/process-tools.js +128 -0
- package/dist/tools/registry.js +194 -0
- package/dist/tools/schema-lock.js +152 -0
- package/dist/tools/search-tools.js +176 -0
- package/dist/tools/security-tools.js +147 -0
- package/dist/tools/terminal-tools.js +57 -0
- package/dist/tools/workspace-tools.js +186 -0
- package/dist/workspace/memory-store.js +67 -0
- package/dist/workspace/onboarding.js +46 -0
- package/dist/workspace/project-detector.js +95 -0
- package/dist/workspace/workspace-manager.js +106 -0
- package/docs/adapters.md +76 -0
- package/docs/architecture.md +66 -0
- package/docs/roadmap.md +172 -0
- package/docs/security.md +94 -0
- package/docs/tools.md +129 -0
- package/examples/claude-desktop.json +18 -0
- package/examples/codex.toml +18 -0
- package/examples/config.basic.yaml +37 -0
- package/examples/config.full.yaml +120 -0
- package/package.json +74 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineTool } from './registry.js';
|
|
3
|
+
import { detectCommands } from '../workspace/project-detector.js';
|
|
4
|
+
import { onboardProject } from '../workspace/onboarding.js';
|
|
5
|
+
import { TASK_PRESETS } from './index.js';
|
|
6
|
+
export function workspaceTools() {
|
|
7
|
+
return [
|
|
8
|
+
defineTool({
|
|
9
|
+
name: 'workspace_activate',
|
|
10
|
+
description: 'Activate a local project as the active workspace.',
|
|
11
|
+
group: 'workspace',
|
|
12
|
+
mutates: true,
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: { path: { type: 'string', description: 'Absolute path to the project folder.' } },
|
|
16
|
+
required: ['path'],
|
|
17
|
+
},
|
|
18
|
+
handler: async (args, ctx) => {
|
|
19
|
+
const info = ctx.container.workspace.activate(String(args.path));
|
|
20
|
+
ctx.container.audit.record({ type: 'workspace_activate', summary: info.projectRoot });
|
|
21
|
+
return { ok: true, data: info };
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
defineTool({
|
|
25
|
+
name: 'workspace_list',
|
|
26
|
+
description: 'List all activated workspaces and which one is current.',
|
|
27
|
+
group: 'workspace',
|
|
28
|
+
mutates: false,
|
|
29
|
+
inputSchema: { type: 'object', properties: {} },
|
|
30
|
+
handler: async (_args, ctx) => ({
|
|
31
|
+
ok: true,
|
|
32
|
+
data: { workspaces: ctx.container.workspace.list() },
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
defineTool({
|
|
36
|
+
name: 'workspace_switch',
|
|
37
|
+
description: 'Switch the current workspace to another already-activated project. ' +
|
|
38
|
+
'Path-less tool calls then operate on this workspace.',
|
|
39
|
+
group: 'workspace',
|
|
40
|
+
mutates: true,
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: { path: { type: 'string', description: 'Absolute path of an activated project.' } },
|
|
44
|
+
required: ['path'],
|
|
45
|
+
},
|
|
46
|
+
handler: async (args, ctx) => {
|
|
47
|
+
try {
|
|
48
|
+
const info = ctx.container.workspace.setCurrent(String(args.path));
|
|
49
|
+
ctx.container.audit.record({ type: 'workspace_activate', summary: `switch ${info.projectRoot}` });
|
|
50
|
+
return { ok: true, data: info };
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
defineTool({
|
|
58
|
+
name: 'workspace_deactivate',
|
|
59
|
+
description: 'Deactivate a workspace. If it was current, the most recent remaining one becomes current.',
|
|
60
|
+
group: 'workspace',
|
|
61
|
+
mutates: true,
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: { path: { type: 'string', description: 'Absolute path of the project to deactivate.' } },
|
|
65
|
+
required: ['path'],
|
|
66
|
+
},
|
|
67
|
+
handler: async (args, ctx) => {
|
|
68
|
+
const removed = ctx.container.workspace.deactivate(String(args.path));
|
|
69
|
+
return removed
|
|
70
|
+
? { ok: true, data: { deactivated: String(args.path), workspaces: ctx.container.workspace.list() } }
|
|
71
|
+
: { ok: false, error: `Workspace was not active: ${String(args.path)}` };
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
defineTool({
|
|
75
|
+
name: 'workspace_status',
|
|
76
|
+
description: 'Return the active workspace, policy mode, allowed directories, and enabled adapters.',
|
|
77
|
+
group: 'workspace',
|
|
78
|
+
mutates: false,
|
|
79
|
+
inputSchema: { type: 'object', properties: {} },
|
|
80
|
+
handler: async (_args, ctx) => {
|
|
81
|
+
const active = ctx.container.workspace.getActive();
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
data: {
|
|
85
|
+
active: Boolean(active),
|
|
86
|
+
project: active,
|
|
87
|
+
mode: ctx.container.policy.getMode(),
|
|
88
|
+
allowedDirectories: ctx.config.workspace.allowedDirectories,
|
|
89
|
+
adapters: ctx.container.adapters.status(),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
defineTool({
|
|
95
|
+
name: 'workspace_onboard',
|
|
96
|
+
description: 'Scan the project and generate memory files (overview, commands, conventions, testing).',
|
|
97
|
+
group: 'workspace',
|
|
98
|
+
mutates: true,
|
|
99
|
+
inputSchema: { type: 'object', properties: {} },
|
|
100
|
+
handler: async (_args, ctx) => {
|
|
101
|
+
const root = ctx.container.workspace.requireActive().projectRoot;
|
|
102
|
+
const memory = ctx.container.workspace.getMemory();
|
|
103
|
+
const result = onboardProject(root, memory);
|
|
104
|
+
return { ok: true, data: result };
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
defineTool({
|
|
108
|
+
name: 'workspace_health',
|
|
109
|
+
description: 'Run health checks: folder, git, package manager, adapters, detected commands, policy.',
|
|
110
|
+
group: 'workspace',
|
|
111
|
+
mutates: false,
|
|
112
|
+
inputSchema: { type: 'object', properties: {} },
|
|
113
|
+
handler: async (_args, ctx) => {
|
|
114
|
+
const active = ctx.container.workspace.getActive();
|
|
115
|
+
const root = active?.projectRoot ?? ctx.projectRoot;
|
|
116
|
+
const cmds = active ? detectCommands(root) : { packageManager: null, scripts: {} };
|
|
117
|
+
const serena = await ctx.container.adapters.health('serena').catch(() => ({ enabled: false, ready: false }));
|
|
118
|
+
const playwright = await ctx.container.adapters
|
|
119
|
+
.health('playwright')
|
|
120
|
+
.catch(() => ({ enabled: false, ready: false }));
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
data: {
|
|
124
|
+
folderExists: existsSync(root),
|
|
125
|
+
gitRepo: active?.git ?? false,
|
|
126
|
+
packageManager: cmds.packageManager,
|
|
127
|
+
commandsDetected: Object.keys(cmds.scripts),
|
|
128
|
+
adapters: { serena, playwright },
|
|
129
|
+
policyLoaded: true,
|
|
130
|
+
mode: ctx.container.policy.getMode(),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
defineTool({
|
|
136
|
+
name: 'workspace_route',
|
|
137
|
+
description: 'Switch the visible tool set to a task preset (explore, run_ui, fix_tests) ' +
|
|
138
|
+
'or pass reset=true / preset="all" to expose every tool again.',
|
|
139
|
+
group: 'workspace',
|
|
140
|
+
mutates: false,
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
preset: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
description: 'Preset name: explore | run_ui | fix_tests | all',
|
|
147
|
+
enum: [...Object.keys(TASK_PRESETS), 'all'],
|
|
148
|
+
},
|
|
149
|
+
reset: { type: 'boolean', description: 'Expose every tool again (same as preset=all).' },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
handler: async (args, ctx) => {
|
|
153
|
+
const registry = ctx.container.registry;
|
|
154
|
+
if (!registry) {
|
|
155
|
+
return { ok: false, error: 'Tool registry is not available for routing.' };
|
|
156
|
+
}
|
|
157
|
+
const presets = Object.keys(TASK_PRESETS);
|
|
158
|
+
const reset = args.reset === true || args.preset === 'all';
|
|
159
|
+
if (reset) {
|
|
160
|
+
registry.setActive(null);
|
|
161
|
+
ctx.container.audit.record({ type: 'workspace_route', summary: 'all' });
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
data: { preset: 'all', active: registry.listActive().map((t) => t.name) },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const preset = args.preset === undefined ? undefined : String(args.preset);
|
|
168
|
+
if (!preset) {
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
data: { presets, hint: 'Pass preset=<name> to focus the tool set, or reset=true to show all.' },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (!presets.includes(preset)) {
|
|
175
|
+
return { ok: false, error: `Unknown preset "${preset}". Available: ${presets.join(', ')}, all.` };
|
|
176
|
+
}
|
|
177
|
+
registry.setActive(TASK_PRESETS[preset]);
|
|
178
|
+
ctx.container.audit.record({ type: 'workspace_route', summary: preset });
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
data: { preset, active: registry.listActive().map((t) => t.name) },
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
];
|
|
186
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Ensure FolderForge's own state directory never pollutes the host project's
|
|
5
|
+
* git status. We drop a self-ignoring `.gitignore` (`*`) into `.folderforge/`,
|
|
6
|
+
* which makes git ignore the entire directory - including the `.gitignore`
|
|
7
|
+
* itself - so `git status` stays clean for the user's repository.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent and safe to call on every workspace activation.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureStateDirIgnored(projectRoot) {
|
|
12
|
+
const stateDir = join(projectRoot, '.folderforge');
|
|
13
|
+
if (!existsSync(stateDir)) {
|
|
14
|
+
mkdirSync(stateDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
const ignore = join(stateDir, '.gitignore');
|
|
17
|
+
if (!existsSync(ignore)) {
|
|
18
|
+
writeFileSync(ignore, '*\n', 'utf8');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Memory store backed by markdown files under .folderforge/memory/.
|
|
23
|
+
*/
|
|
24
|
+
export class MemoryStore {
|
|
25
|
+
dir;
|
|
26
|
+
constructor(projectRoot) {
|
|
27
|
+
this.dir = join(projectRoot, '.folderforge', 'memory');
|
|
28
|
+
if (!existsSync(this.dir)) {
|
|
29
|
+
mkdirSync(this.dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
// Keep FolderForge's state out of the user's git status.
|
|
32
|
+
ensureStateDirIgnored(projectRoot);
|
|
33
|
+
}
|
|
34
|
+
safeName(name) {
|
|
35
|
+
const base = name.endsWith('.md') ? name : `${name}.md`;
|
|
36
|
+
if (base.includes('/') || base.includes('..') || base.includes('\\')) {
|
|
37
|
+
throw new Error(`Invalid memory name: ${name}`);
|
|
38
|
+
}
|
|
39
|
+
return base;
|
|
40
|
+
}
|
|
41
|
+
list() {
|
|
42
|
+
if (!existsSync(this.dir))
|
|
43
|
+
return [];
|
|
44
|
+
return readdirSync(this.dir).filter((f) => f.endsWith('.md'));
|
|
45
|
+
}
|
|
46
|
+
read(name) {
|
|
47
|
+
const path = join(this.dir, this.safeName(name));
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
throw new Error(`Memory not found: ${name}`);
|
|
50
|
+
return readFileSync(path, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
write(name, content) {
|
|
53
|
+
const path = join(this.dir, this.safeName(name));
|
|
54
|
+
writeFileSync(path, content, 'utf8');
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
update(name, append) {
|
|
58
|
+
const file = this.safeName(name);
|
|
59
|
+
const path = join(this.dir, file);
|
|
60
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
61
|
+
writeFileSync(path, existing + (existing ? '\n' : '') + append, 'utf8');
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
dir_() {
|
|
65
|
+
return this.dir;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { detectProject, detectCommands } from './project-detector.js';
|
|
2
|
+
const TEMPLATE = (info, cmds) => {
|
|
3
|
+
const overview = `# Project Overview: ${info.name}
|
|
4
|
+
|
|
5
|
+
- Root: ${info.projectRoot}
|
|
6
|
+
- Languages: ${info.languageHints.join(', ') || 'unknown'}
|
|
7
|
+
- Package managers: ${info.packageManagers.join(', ') || 'unknown'}
|
|
8
|
+
- Git: ${info.git ? 'yes' : 'no'}
|
|
9
|
+
|
|
10
|
+
_This file was generated by FolderForge onboarding. Edit freely._
|
|
11
|
+
`;
|
|
12
|
+
const commands = `# Commands
|
|
13
|
+
|
|
14
|
+
- Package manager: ${cmds.packageManager ?? 'unknown'}
|
|
15
|
+
- Test framework: ${cmds.testFramework ?? 'unknown'}
|
|
16
|
+
|
|
17
|
+
| Task | Command |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
${Object.entries(cmds.scripts)
|
|
20
|
+
.map(([k, v]) => `| ${k} | \`${v}\` |`)
|
|
21
|
+
.join('\n') || '| (none detected) | |'}
|
|
22
|
+
`;
|
|
23
|
+
const conventions = `# Coding Conventions
|
|
24
|
+
|
|
25
|
+
- Detected languages: ${info.languageHints.join(', ') || 'unknown'}
|
|
26
|
+
- Add team conventions here (formatting, imports, naming, error handling).
|
|
27
|
+
`;
|
|
28
|
+
const testing = `# Testing Strategy
|
|
29
|
+
|
|
30
|
+
- Framework: ${cmds.testFramework ?? 'unknown'}
|
|
31
|
+
- Run with: \`${cmds.scripts.test ?? 'TBD'}\`
|
|
32
|
+
- Describe coverage expectations and CI here.
|
|
33
|
+
`;
|
|
34
|
+
return { overview, commands, conventions, testing };
|
|
35
|
+
};
|
|
36
|
+
export function onboardProject(root, memory) {
|
|
37
|
+
const project = detectProject(root);
|
|
38
|
+
const commands = detectCommands(root);
|
|
39
|
+
const t = TEMPLATE(project, commands);
|
|
40
|
+
const written = [];
|
|
41
|
+
written.push(memory.write('project_overview.md', t.overview));
|
|
42
|
+
written.push(memory.write('commands.md', t.commands));
|
|
43
|
+
written.push(memory.write('coding_conventions.md', t.conventions));
|
|
44
|
+
written.push(memory.write('testing_strategy.md', t.testing));
|
|
45
|
+
return { project, commands, writtenMemories: written };
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
function readJson(path) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function detectProject(root) {
|
|
12
|
+
const languageHints = new Set();
|
|
13
|
+
const packageManagers = new Set();
|
|
14
|
+
const checks = [
|
|
15
|
+
['package.json', () => languageHints.add('typescript')],
|
|
16
|
+
['tsconfig.json', () => languageHints.add('typescript')],
|
|
17
|
+
['pyproject.toml', () => languageHints.add('python')],
|
|
18
|
+
['requirements.txt', () => languageHints.add('python')],
|
|
19
|
+
['go.mod', () => languageHints.add('go')],
|
|
20
|
+
['Cargo.toml', () => languageHints.add('rust')],
|
|
21
|
+
['composer.json', () => languageHints.add('php')],
|
|
22
|
+
['pom.xml', () => languageHints.add('java')],
|
|
23
|
+
['build.gradle', () => languageHints.add('java')],
|
|
24
|
+
];
|
|
25
|
+
for (const [file, fn] of checks) {
|
|
26
|
+
if (existsSync(join(root, file)))
|
|
27
|
+
fn();
|
|
28
|
+
}
|
|
29
|
+
if (existsSync(join(root, 'pnpm-lock.yaml')))
|
|
30
|
+
packageManagers.add('pnpm');
|
|
31
|
+
else if (existsSync(join(root, 'yarn.lock')))
|
|
32
|
+
packageManagers.add('yarn');
|
|
33
|
+
else if (existsSync(join(root, 'package-lock.json')))
|
|
34
|
+
packageManagers.add('npm');
|
|
35
|
+
else if (existsSync(join(root, 'package.json')))
|
|
36
|
+
packageManagers.add('npm');
|
|
37
|
+
if (existsSync(join(root, 'pyproject.toml')))
|
|
38
|
+
packageManagers.add('pip');
|
|
39
|
+
if (existsSync(join(root, 'go.mod')))
|
|
40
|
+
packageManagers.add('go');
|
|
41
|
+
if (existsSync(join(root, 'Cargo.toml')))
|
|
42
|
+
packageManagers.add('cargo');
|
|
43
|
+
return {
|
|
44
|
+
projectRoot: root,
|
|
45
|
+
name: basename(root),
|
|
46
|
+
languageHints: [...languageHints],
|
|
47
|
+
packageManagers: [...packageManagers],
|
|
48
|
+
git: existsSync(join(root, '.git')),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function detectCommands(root) {
|
|
52
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
53
|
+
if (pkg && typeof pkg.scripts === 'object' && pkg.scripts) {
|
|
54
|
+
const scripts = pkg.scripts;
|
|
55
|
+
let pm = 'npm';
|
|
56
|
+
if (existsSync(join(root, 'pnpm-lock.yaml')))
|
|
57
|
+
pm = 'pnpm';
|
|
58
|
+
else if (existsSync(join(root, 'yarn.lock')))
|
|
59
|
+
pm = 'yarn';
|
|
60
|
+
const norm = {};
|
|
61
|
+
for (const key of ['dev', 'start', 'test', 'build', 'lint', 'typecheck']) {
|
|
62
|
+
if (scripts[key])
|
|
63
|
+
norm[key] = `${pm} run ${key}`;
|
|
64
|
+
}
|
|
65
|
+
let testFramework;
|
|
66
|
+
const deps = {
|
|
67
|
+
...pkg.dependencies,
|
|
68
|
+
...pkg.devDependencies,
|
|
69
|
+
};
|
|
70
|
+
if (deps?.vitest)
|
|
71
|
+
testFramework = 'vitest';
|
|
72
|
+
else if (deps?.jest)
|
|
73
|
+
testFramework = 'jest';
|
|
74
|
+
else if (deps?.mocha)
|
|
75
|
+
testFramework = 'mocha';
|
|
76
|
+
return { packageManager: pm, scripts: norm, ...(testFramework ? { testFramework } : {}) };
|
|
77
|
+
}
|
|
78
|
+
if (existsSync(join(root, 'pyproject.toml')) || existsSync(join(root, 'requirements.txt'))) {
|
|
79
|
+
return {
|
|
80
|
+
packageManager: 'pip',
|
|
81
|
+
scripts: { test: 'pytest', lint: 'ruff check .', typecheck: 'mypy .' },
|
|
82
|
+
testFramework: 'pytest',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (existsSync(join(root, 'go.mod'))) {
|
|
86
|
+
return { packageManager: 'go', scripts: { test: 'go test ./...', build: 'go build ./...' }, testFramework: 'go test' };
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(join(root, 'Cargo.toml'))) {
|
|
89
|
+
return { packageManager: 'cargo', scripts: { test: 'cargo test', build: 'cargo build' }, testFramework: 'cargo test' };
|
|
90
|
+
}
|
|
91
|
+
if (existsSync(join(root, 'Makefile'))) {
|
|
92
|
+
return { packageManager: 'make', scripts: { test: 'make test', build: 'make build' } };
|
|
93
|
+
}
|
|
94
|
+
return { packageManager: null, scripts: {} };
|
|
95
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { detectProject } from './project-detector.js';
|
|
4
|
+
import { MemoryStore } from './memory-store.js';
|
|
5
|
+
import { logger } from '../core/logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Tracks one or more activated projects and which one is "current".
|
|
8
|
+
*
|
|
9
|
+
* Multi-project support: several workspaces can be active at once (keyed by
|
|
10
|
+
* absolute root). `current` points at the workspace that path-less tool calls
|
|
11
|
+
* operate on; switch it with {@link setCurrent}. The single-project API
|
|
12
|
+
* (`activate`, `getActive`, `requireActive`, `projectRoot`, `getMemory`) is
|
|
13
|
+
* preserved and always refers to the current workspace.
|
|
14
|
+
*/
|
|
15
|
+
export class WorkspaceManager {
|
|
16
|
+
allowedDirectories;
|
|
17
|
+
sessions = new Map();
|
|
18
|
+
current = null;
|
|
19
|
+
constructor(allowedDirectories) {
|
|
20
|
+
this.allowedDirectories = allowedDirectories;
|
|
21
|
+
}
|
|
22
|
+
assertAllowed(abs) {
|
|
23
|
+
const allowed = this.allowedDirectories.some((d) => abs === resolve(d) || abs.startsWith(resolve(d)));
|
|
24
|
+
if (!allowed) {
|
|
25
|
+
throw new Error(`Project path is not within allowed directories: ${abs}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Activate a project and make it the current workspace. If it was already
|
|
30
|
+
* activated, this simply re-selects it as current.
|
|
31
|
+
*/
|
|
32
|
+
activate(path) {
|
|
33
|
+
const abs = resolve(path);
|
|
34
|
+
if (!existsSync(abs)) {
|
|
35
|
+
throw new Error(`Project path does not exist: ${abs}`);
|
|
36
|
+
}
|
|
37
|
+
this.assertAllowed(abs);
|
|
38
|
+
let session = this.sessions.get(abs);
|
|
39
|
+
if (!session) {
|
|
40
|
+
session = { info: detectProject(abs), memory: new MemoryStore(abs), root: abs, activatedAt: Date.now() };
|
|
41
|
+
this.sessions.set(abs, session);
|
|
42
|
+
logger.info({ project: session.info.name, root: abs }, 'Workspace activated');
|
|
43
|
+
}
|
|
44
|
+
this.current = abs;
|
|
45
|
+
return session.info;
|
|
46
|
+
}
|
|
47
|
+
/** Switch the current workspace to an already-activated project. */
|
|
48
|
+
setCurrent(path) {
|
|
49
|
+
const abs = resolve(path);
|
|
50
|
+
const session = this.sessions.get(abs);
|
|
51
|
+
if (!session) {
|
|
52
|
+
throw new Error(`Workspace not activated: ${abs}. Call workspace_activate first.`);
|
|
53
|
+
}
|
|
54
|
+
this.current = abs;
|
|
55
|
+
logger.info({ project: session.info.name, root: abs }, 'Current workspace switched');
|
|
56
|
+
return session.info;
|
|
57
|
+
}
|
|
58
|
+
/** Deactivate a workspace. If it was current, current falls back to most recent. */
|
|
59
|
+
deactivate(path) {
|
|
60
|
+
const abs = resolve(path);
|
|
61
|
+
const existed = this.sessions.delete(abs);
|
|
62
|
+
if (this.current === abs) {
|
|
63
|
+
const remaining = [...this.sessions.values()].sort((a, b) => b.activatedAt - a.activatedAt);
|
|
64
|
+
this.current = remaining[0]?.root ?? null;
|
|
65
|
+
}
|
|
66
|
+
return existed;
|
|
67
|
+
}
|
|
68
|
+
/** All activated workspaces, with a flag for the current one. */
|
|
69
|
+
list() {
|
|
70
|
+
return [...this.sessions.values()].map((s) => ({
|
|
71
|
+
...s.info,
|
|
72
|
+
root: s.root,
|
|
73
|
+
current: s.root === this.current,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
currentSession() {
|
|
77
|
+
return this.current ? this.sessions.get(this.current) ?? null : null;
|
|
78
|
+
}
|
|
79
|
+
getActive() {
|
|
80
|
+
return this.currentSession()?.info ?? null;
|
|
81
|
+
}
|
|
82
|
+
requireActive() {
|
|
83
|
+
const s = this.currentSession();
|
|
84
|
+
if (!s)
|
|
85
|
+
throw new Error('No active workspace. Call workspace_activate first.');
|
|
86
|
+
return s.info;
|
|
87
|
+
}
|
|
88
|
+
projectRoot() {
|
|
89
|
+
return this.currentSession()?.info.projectRoot ?? null;
|
|
90
|
+
}
|
|
91
|
+
getMemory() {
|
|
92
|
+
const s = this.currentSession();
|
|
93
|
+
if (!s)
|
|
94
|
+
throw new Error('No active workspace memory store.');
|
|
95
|
+
return s.memory;
|
|
96
|
+
}
|
|
97
|
+
/** Memory store for a specific activated workspace (defaults to current). */
|
|
98
|
+
getMemoryFor(path) {
|
|
99
|
+
if (!path)
|
|
100
|
+
return this.getMemory();
|
|
101
|
+
const s = this.sessions.get(resolve(path));
|
|
102
|
+
if (!s)
|
|
103
|
+
throw new Error(`Workspace not activated: ${resolve(path)}`);
|
|
104
|
+
return s.memory;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/docs/adapters.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Adapters (child MCP servers)
|
|
2
|
+
|
|
3
|
+
FolderForge can proxy other MCP servers so the agent sees one unified tool
|
|
4
|
+
surface. Adapters are configured under `adapters` in the config and managed by
|
|
5
|
+
`src/adapters/child-mcp/registry.ts` (spawning) and `client.ts` (the MCP client
|
|
6
|
+
that talks to each child over stdio).
|
|
7
|
+
|
|
8
|
+
## Configuration
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
adapters:
|
|
12
|
+
serena:
|
|
13
|
+
enabled: false
|
|
14
|
+
command: serena
|
|
15
|
+
args: []
|
|
16
|
+
playwright:
|
|
17
|
+
enabled: true
|
|
18
|
+
command: npx
|
|
19
|
+
args: ["-y", "@playwright/mcp@latest"]
|
|
20
|
+
desktopCommander:
|
|
21
|
+
enabled: false
|
|
22
|
+
command: npx
|
|
23
|
+
args: ["-y", "@wonderwhy-er/desktop-commander@latest"]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Each adapter (`AdapterDef`) has:
|
|
27
|
+
|
|
28
|
+
- `enabled` - whether to spawn it on startup;
|
|
29
|
+
- `command` / `args` - how to launch the child server;
|
|
30
|
+
- `env` - optional extra environment (subject to secret redaction).
|
|
31
|
+
|
|
32
|
+
## Lifecycle
|
|
33
|
+
|
|
34
|
+
1. On startup, `ChildMcpRegistry` reads enabled adapters from config.
|
|
35
|
+
2. Adapters start **lazily** - the child process is spawned on first use (or first
|
|
36
|
+
tool discovery), not eagerly, to avoid paying their cost when unused.
|
|
37
|
+
3. The child's tools are discovered via `tools/list`, **namespaced** with the
|
|
38
|
+
adapter name and a `__` separator (e.g. `serena__find_symbol`), and
|
|
39
|
+
re-exported through the main registry.
|
|
40
|
+
4. Calls are still wrapped by the FolderForge policy + audit pipeline before
|
|
41
|
+
being forwarded to the child.
|
|
42
|
+
5. On shutdown, `stopAll()` terminates every spawned child.
|
|
43
|
+
|
|
44
|
+
## Tool namespacing
|
|
45
|
+
|
|
46
|
+
Every proxied tool is exposed as `<adapter>__<childToolName>`:
|
|
47
|
+
|
|
48
|
+
| Adapter | Example namespaced tools |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `serena` | `serena__find_symbol`, `serena__find_referencing_symbols` |
|
|
51
|
+
| `playwright` | `playwright__browser_navigate`, `playwright__browser_snapshot` |
|
|
52
|
+
| `desktopCommander` | `desktopCommander__<tool>` |
|
|
53
|
+
|
|
54
|
+
The separator is the `NS_SEP` constant in `src/tools/adapter-tools.ts`. Proxied
|
|
55
|
+
tools default to `MEDIUM` risk and `mutates: true`, so policy mode and the
|
|
56
|
+
approval list still gate them.
|
|
57
|
+
|
|
58
|
+
## Provided integrations
|
|
59
|
+
|
|
60
|
+
| Adapter | Purpose | Tool prefix |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| Serena | Semantic code intelligence (symbols, references) | `serena__*` |
|
|
63
|
+
| Playwright | Browser automation and inspection | `playwright__*` |
|
|
64
|
+
| Desktop Commander | Extended local desktop control (optional) | `desktopCommander__*` |
|
|
65
|
+
|
|
66
|
+
## Writing a new adapter
|
|
67
|
+
|
|
68
|
+
1. Add an `AdapterDef` entry to `AdaptersConfig` in `src/core/types.ts`.
|
|
69
|
+
2. Provide a default in `src/core/config.ts`.
|
|
70
|
+
3. Add its name to `ADAPTER_NAMES` in `src/tools/adapter-tools.ts` and to
|
|
71
|
+
`AdapterName` in `src/adapters/child-mcp/registry.ts` so its tools are
|
|
72
|
+
discovered, namespaced, and proxied.
|
|
73
|
+
|
|
74
|
+
Because every proxied call passes through `PolicyEngine.evaluate`, child tools
|
|
75
|
+
inherit the same risk classification, approval, and audit guarantees as native
|
|
76
|
+
tools - decide their risk in `TOOL_RISK` (`src/policy/risk.ts`).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
FolderForge is an MCP-native control plane that sits between an AI
|
|
4
|
+
coding agent and a local development workspace. It exposes a single, curated set
|
|
5
|
+
of tools over the Model Context Protocol and wraps every call in a policy +
|
|
6
|
+
audit pipeline.
|
|
7
|
+
|
|
8
|
+
## High-level flow
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
agent (Claude Desktop / Codex / ...)
|
|
12
|
+
| JSON-RPC (MCP)
|
|
13
|
+
v
|
|
14
|
+
transport (stdio | streamable http) src/server/transports/*
|
|
15
|
+
|
|
|
16
|
+
v
|
|
17
|
+
MCP Server src/server/mcp-server.ts
|
|
18
|
+
| tools/list -> registry.listActive()
|
|
19
|
+
| tools/call -> registry.call()
|
|
20
|
+
v
|
|
21
|
+
ToolRegistry --------------------------- src/tools/registry.ts
|
|
22
|
+
| classify risk -> PolicyEngine.evaluate()
|
|
23
|
+
| audit.record()
|
|
24
|
+
v
|
|
25
|
+
PolicyEngine --------------------------- src/policy/policy-engine.ts
|
|
26
|
+
(path / command / secret / approvals)
|
|
27
|
+
| allow | deny | approval
|
|
28
|
+
v
|
|
29
|
+
Tool handler (file/git/shell/...) src/tools/*-tools.ts
|
|
30
|
+
|
|
|
31
|
+
v
|
|
32
|
+
Container services src/core/container.ts
|
|
33
|
+
(workspace, processes, db, adapters, audit)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A second, independent HTTP server serves the local dashboard
|
|
37
|
+
(`src/dashboard/server.ts`), which reads the same `Container` to render status,
|
|
38
|
+
audit, processes, and approvals.
|
|
39
|
+
|
|
40
|
+
## Modules
|
|
41
|
+
|
|
42
|
+
| Area | Path | Responsibility |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Entrypoint | `src/main.ts` | Parse args, `loadConfig`, build `Container` + registry, start server + dashboard |
|
|
45
|
+
| Config | `src/core/config.ts` | Defaults + YAML merge + path normalization |
|
|
46
|
+
| Container | `src/core/container.ts` | Dependency container shared by all handlers |
|
|
47
|
+
| Registry | `src/tools/registry.ts` | Tool catalog, active subset, policy + audit pipeline |
|
|
48
|
+
| Server | `src/server/mcp-server.ts` | MCP `tools/list` and `tools/call` handlers |
|
|
49
|
+
| Transports | `src/server/transports/*` | stdio and Streamable HTTP binding |
|
|
50
|
+
| Policy | `src/policy/*` | Path, command, secret policies; risk; approvals |
|
|
51
|
+
| Audit | `src/audit/*` | Append-only JSONL log + ring buffer |
|
|
52
|
+
| Managers | `src/managers/*` | Long-running processes, DB connections |
|
|
53
|
+
| Workspace | `src/workspace/*` | Project detection, activation, memory store |
|
|
54
|
+
| Adapters | `src/adapters/child-mcp/*` | Proxy child MCP servers (Serena, Playwright) |
|
|
55
|
+
| Dashboard | `src/dashboard/*` | Local read/approve control plane UI |
|
|
56
|
+
|
|
57
|
+
## Design principles
|
|
58
|
+
|
|
59
|
+
- **stdout is sacred.** On the stdio transport, stdout carries the JSON-RPC
|
|
60
|
+
channel only. All logs go to stderr (`src/core/logger.ts`).
|
|
61
|
+
- **One decision point.** Every mutation flows through `PolicyEngine.evaluate`.
|
|
62
|
+
Tools never bypass it.
|
|
63
|
+
- **Curated surface.** The registry can expose a routed subset
|
|
64
|
+
(`TASK_PRESETS`) so agents see a focused tool list instead of everything.
|
|
65
|
+
- **Fail safe.** Unknown tools, denied paths, and CRITICAL commands return a
|
|
66
|
+
structured error rather than throwing across the protocol boundary.
|