@co-syn/forest 0.1.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 +102 -0
- package/build/cli.d.ts +9 -0
- package/build/cli.js +150 -0
- package/build/hook.d.ts +11 -0
- package/build/hook.js +142 -0
- package/build/lib/api.d.ts +9 -0
- package/build/lib/api.js +20 -0
- package/build/lib/config.d.ts +19 -0
- package/build/lib/config.js +41 -0
- package/build/mcp.d.ts +13 -0
- package/build/mcp.js +191 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @co-syn/forest
|
|
2
|
+
|
|
3
|
+
Bridge your Claude Code sessions with your team. Share context, sync plans, track tasks — one command to set up.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @co-syn/forest init --token forestpat_YOUR_TOKEN
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's it. This registers the MCP server, configures the plan sync hook, and saves your credentials.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
### MCP Tools (Claude calls these)
|
|
16
|
+
|
|
17
|
+
| Tool | Description |
|
|
18
|
+
|------|-------------|
|
|
19
|
+
| `forest_sync` | Share a summary of your session with the team via Slack |
|
|
20
|
+
| `forest_tasks` | List active tasks from Forest |
|
|
21
|
+
| `forest_update` | Update a task's status or add notes |
|
|
22
|
+
|
|
23
|
+
### Plan Sync Hook (automatic)
|
|
24
|
+
|
|
25
|
+
When you exit plan mode in Claude Code ("Accept Edits"), the plan markdown is automatically sent to Forest. From there, Forest can:
|
|
26
|
+
- Match the plan to active tasks
|
|
27
|
+
- Post to your team's Slack channel
|
|
28
|
+
- Update project context
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
### 1. Get your token
|
|
33
|
+
|
|
34
|
+
Generate a personal API token from the Forest web app settings page.
|
|
35
|
+
|
|
36
|
+
### 2. Run init
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx @co-syn/forest init --token forestpat_YOUR_TOKEN
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For a custom backend URL:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @co-syn/forest init --token forestpat_YOUR_TOKEN --api-url https://your-backend.example.com
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3. Use it
|
|
49
|
+
|
|
50
|
+
In Claude Code, just ask naturally:
|
|
51
|
+
|
|
52
|
+
- "sync what we did to the team"
|
|
53
|
+
- "share this with @john on #engineering"
|
|
54
|
+
- "show me active tasks"
|
|
55
|
+
- "mark the auth task as done"
|
|
56
|
+
|
|
57
|
+
Plans sync automatically when you accept edits from plan mode — no action needed.
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
npx @co-syn/forest init
|
|
63
|
+
├── Saves token to ~/.config/forest/config.json
|
|
64
|
+
├── Registers MCP server with Claude Code
|
|
65
|
+
└── Adds ExitPlanMode hook to ~/.claude/settings.json
|
|
66
|
+
|
|
67
|
+
During a session:
|
|
68
|
+
Claude Code ←─stdio──→ forest mcp (MCP server, local process)
|
|
69
|
+
│
|
|
70
|
+
HTTPS + Bearer token
|
|
71
|
+
│
|
|
72
|
+
▼
|
|
73
|
+
Forest Backend (your org)
|
|
74
|
+
├── Slack
|
|
75
|
+
├── Tasks DB
|
|
76
|
+
└── Normalized Events
|
|
77
|
+
|
|
78
|
+
On "Accept Edits":
|
|
79
|
+
ExitPlanMode hook fires → forest hook plan-exit
|
|
80
|
+
→ reads transcript → extracts plan markdown
|
|
81
|
+
→ POST /mcp/plan to Forest backend
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Local development
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx @co-syn/forest init --token forestpat_YOUR_TOKEN --api-url http://localhost:8787
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or run the MCP server directly:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
FOREST_API_TOKEN=forestpat_... FOREST_API_URL=http://localhost:8787 node packages/forest-mcp/build/mcp.js
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## CLI reference
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
npx @co-syn/forest init --token <token> # Set up everything
|
|
100
|
+
npx @co-syn/forest mcp # Start MCP server (used by Claude Code)
|
|
101
|
+
npx @co-syn/forest hook plan-exit # Run plan-exit hook (used by Claude Code)
|
|
102
|
+
```
|
package/build/cli.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forest CLI — setup tool for Forest + Claude Code integration.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @co-syn/forest init --token forestpat_abc123
|
|
7
|
+
* npx @co-syn/forest init --token forestpat_abc123 --api-url https://api.example.com
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/build/cli.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forest CLI — setup tool for Forest + Claude Code integration.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @co-syn/forest init --token forestpat_abc123
|
|
7
|
+
* npx @co-syn/forest init --token forestpat_abc123 --api-url https://api.example.com
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
import { writeConfigFile, resolveConfig } from "./lib/config.js";
|
|
14
|
+
import { forestApi } from "./lib/api.js";
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const args = {};
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
if (argv[i].startsWith("--") && i + 1 < argv.length) {
|
|
19
|
+
const key = argv[i].slice(2);
|
|
20
|
+
args[key] = argv[i + 1];
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return args;
|
|
25
|
+
}
|
|
26
|
+
function readClaudeSettings() {
|
|
27
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function writeClaudeSettings(settings) {
|
|
36
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
37
|
+
mkdirSync(join(homedir(), ".claude"), { recursive: true });
|
|
38
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
39
|
+
}
|
|
40
|
+
function mergeHookConfig(settings) {
|
|
41
|
+
const hooks = (settings.hooks ?? {});
|
|
42
|
+
const preToolUse = (hooks.PreToolUse ?? []);
|
|
43
|
+
// Check if we already have an ExitPlanMode hook
|
|
44
|
+
const existing = preToolUse.find((h) => h.matcher === "ExitPlanMode");
|
|
45
|
+
if (existing) {
|
|
46
|
+
console.log(" Hook already configured for ExitPlanMode — skipping");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
preToolUse.push({
|
|
50
|
+
matcher: "ExitPlanMode",
|
|
51
|
+
hooks: [
|
|
52
|
+
{
|
|
53
|
+
type: "command",
|
|
54
|
+
command: "npx @co-syn/forest hook plan-exit",
|
|
55
|
+
async: true,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
hooks.PreToolUse = preToolUse;
|
|
60
|
+
settings.hooks = hooks;
|
|
61
|
+
}
|
|
62
|
+
async function init(args) {
|
|
63
|
+
const token = args.token;
|
|
64
|
+
const apiUrl = args["api-url"] ?? "https://api.forestworks.ai";
|
|
65
|
+
if (!token) {
|
|
66
|
+
console.error("Usage: npx @co-syn/forest init --token <forestpat_...>");
|
|
67
|
+
console.error("");
|
|
68
|
+
console.error("Get your token from the Forest web app settings page.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (!token.startsWith("forestpat_")) {
|
|
72
|
+
console.error("Invalid token format. Tokens start with 'forestpat_'.");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
console.log("Setting up Forest for Claude Code...\n");
|
|
76
|
+
// 1. Write config file
|
|
77
|
+
console.log("1. Saving credentials...");
|
|
78
|
+
writeConfigFile({ token, apiUrl });
|
|
79
|
+
console.log(" Saved to ~/.config/forest/config.json\n");
|
|
80
|
+
// 2. Validate token by calling the tasks endpoint
|
|
81
|
+
console.log("2. Validating token...");
|
|
82
|
+
try {
|
|
83
|
+
const config = resolveConfig();
|
|
84
|
+
await forestApi(config, "/tasks");
|
|
85
|
+
console.log(" Token is valid.\n");
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(` Token validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
console.error(" The token was saved but may not work. Check your token and API URL.");
|
|
90
|
+
console.log("");
|
|
91
|
+
}
|
|
92
|
+
// 3. Register MCP server with Claude Code
|
|
93
|
+
console.log("3. Registering MCP server...");
|
|
94
|
+
try {
|
|
95
|
+
execSync(`claude mcp add forest -s user -- npx @co-syn/forest mcp`, { stdio: "pipe" });
|
|
96
|
+
console.log(" MCP server registered.\n");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
console.log(" Could not auto-register. Run manually:");
|
|
100
|
+
console.log(" claude mcp add forest -s user -- npx @co-syn/forest mcp\n");
|
|
101
|
+
}
|
|
102
|
+
// 4. Add hook config
|
|
103
|
+
console.log("4. Configuring plan sync hook...");
|
|
104
|
+
const settings = readClaudeSettings();
|
|
105
|
+
mergeHookConfig(settings);
|
|
106
|
+
writeClaudeSettings(settings);
|
|
107
|
+
console.log(" Hook added to ~/.claude/settings.json\n");
|
|
108
|
+
console.log("Done! Forest is ready.\n");
|
|
109
|
+
console.log("What happens now:");
|
|
110
|
+
console.log(" - Claude Code can share session context via forest_sync");
|
|
111
|
+
console.log(" - Plans auto-sync to Forest when you accept edits");
|
|
112
|
+
console.log(" - View and update tasks with forest_tasks / forest_update");
|
|
113
|
+
}
|
|
114
|
+
// --- Entrypoint ---
|
|
115
|
+
const subcommand = process.argv[2];
|
|
116
|
+
const args = parseArgs(process.argv.slice(3));
|
|
117
|
+
switch (subcommand) {
|
|
118
|
+
case "init":
|
|
119
|
+
init(args).catch((err) => {
|
|
120
|
+
console.error("Setup failed:", err);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
123
|
+
break;
|
|
124
|
+
case "mcp":
|
|
125
|
+
// Forward to MCP server — dynamic import to avoid loading MCP deps for other commands
|
|
126
|
+
import("./mcp.js").catch((err) => {
|
|
127
|
+
console.error("Failed to start MCP server:", err);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
case "hook":
|
|
132
|
+
// Forward to hook handler
|
|
133
|
+
// Re-invoke with correct argv position
|
|
134
|
+
process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
|
|
135
|
+
import("./hook.js").catch((err) => {
|
|
136
|
+
console.error("Hook error:", err);
|
|
137
|
+
process.exit(0); // don't block
|
|
138
|
+
});
|
|
139
|
+
break;
|
|
140
|
+
default:
|
|
141
|
+
console.log("Forest CLI — Claude Code integration\n");
|
|
142
|
+
console.log("Commands:");
|
|
143
|
+
console.log(" init --token <token> Set up Forest for Claude Code");
|
|
144
|
+
console.log(" mcp Start the MCP server (used by Claude Code)");
|
|
145
|
+
console.log(" hook <name> Run a hook handler (used by Claude Code)");
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log("Get started:");
|
|
148
|
+
console.log(" npx @co-syn/forest init --token forestpat_YOUR_TOKEN");
|
|
149
|
+
break;
|
|
150
|
+
}
|
package/build/hook.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forest hook handler — runs when a Claude Code hook fires.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forest-hook plan-exit (called by PreToolUse hook on ExitPlanMode)
|
|
7
|
+
*
|
|
8
|
+
* Reads the hook event JSON from stdin, extracts the plan from the
|
|
9
|
+
* conversation transcript, and POSTs it to the Forest backend.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
package/build/hook.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forest hook handler — runs when a Claude Code hook fires.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* forest-hook plan-exit (called by PreToolUse hook on ExitPlanMode)
|
|
7
|
+
*
|
|
8
|
+
* Reads the hook event JSON from stdin, extracts the plan from the
|
|
9
|
+
* conversation transcript, and POSTs it to the Forest backend.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { resolveConfig } from "./lib/config.js";
|
|
13
|
+
import { forestApi } from "./lib/api.js";
|
|
14
|
+
function extractText(content) {
|
|
15
|
+
if (!content)
|
|
16
|
+
return "";
|
|
17
|
+
if (typeof content === "string")
|
|
18
|
+
return content;
|
|
19
|
+
return content
|
|
20
|
+
.filter((block) => block.type === "text" && block.text)
|
|
21
|
+
.map((block) => block.text)
|
|
22
|
+
.join("\n");
|
|
23
|
+
}
|
|
24
|
+
function extractPlanFromTranscript(transcriptPath) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(transcriptPath, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
33
|
+
const entries = [];
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
try {
|
|
36
|
+
entries.push(JSON.parse(line));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// skip malformed lines
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (entries.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
// Find the last EnterPlanMode — everything after that is the plan
|
|
45
|
+
let planStartIndex = -1;
|
|
46
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
47
|
+
const e = entries[i];
|
|
48
|
+
const isEnterPlan = e.tool_name === "EnterPlanMode" ||
|
|
49
|
+
e.name === "EnterPlanMode" ||
|
|
50
|
+
(e.type === "tool_use" && e.name === "EnterPlanMode") ||
|
|
51
|
+
(e.message?.content &&
|
|
52
|
+
Array.isArray(e.message.content) &&
|
|
53
|
+
e.message.content.some((block) => block.type === "tool_use" && block.name === "EnterPlanMode"));
|
|
54
|
+
if (isEnterPlan) {
|
|
55
|
+
planStartIndex = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (planStartIndex === -1) {
|
|
60
|
+
// No EnterPlanMode found — grab the last few assistant messages as fallback
|
|
61
|
+
const lastAssistant = entries
|
|
62
|
+
.filter((e) => e.role === "assistant" ||
|
|
63
|
+
e.message?.role === "assistant" ||
|
|
64
|
+
e.type === "assistant")
|
|
65
|
+
.slice(-5);
|
|
66
|
+
if (lastAssistant.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
return lastAssistant
|
|
69
|
+
.map((e) => extractText(e.message?.content ?? e.content))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join("\n\n");
|
|
72
|
+
}
|
|
73
|
+
// Collect assistant text content from planStartIndex onward
|
|
74
|
+
const planParts = [];
|
|
75
|
+
for (let i = planStartIndex + 1; i < entries.length; i++) {
|
|
76
|
+
const e = entries[i];
|
|
77
|
+
const isAssistant = e.role === "assistant" ||
|
|
78
|
+
e.message?.role === "assistant" ||
|
|
79
|
+
e.type === "assistant";
|
|
80
|
+
if (!isAssistant)
|
|
81
|
+
continue;
|
|
82
|
+
const text = extractText(e.message?.content ?? e.content);
|
|
83
|
+
if (text.trim())
|
|
84
|
+
planParts.push(text);
|
|
85
|
+
}
|
|
86
|
+
return planParts.length > 0 ? planParts.join("\n\n") : null;
|
|
87
|
+
}
|
|
88
|
+
async function handlePlanExit() {
|
|
89
|
+
const config = resolveConfig();
|
|
90
|
+
if (!config) {
|
|
91
|
+
process.stderr.write("Forest not configured. Run: npx @co-syn/forest init --token <token>\n");
|
|
92
|
+
process.exit(0); // exit 0 so we don't block the tool
|
|
93
|
+
}
|
|
94
|
+
// Read hook event from stdin
|
|
95
|
+
let eventJson;
|
|
96
|
+
try {
|
|
97
|
+
eventJson = readFileSync("/dev/stdin", "utf-8");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// stdin not available — nothing to do
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
let event;
|
|
104
|
+
try {
|
|
105
|
+
event = JSON.parse(eventJson);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
if (!event.transcript_path) {
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
const plan = extractPlanFromTranscript(event.transcript_path);
|
|
114
|
+
if (!plan?.trim()) {
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await forestApi(config, "/plan", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: {
|
|
121
|
+
plan,
|
|
122
|
+
sessionId: event.session_id,
|
|
123
|
+
cwd: event.cwd,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
// Log but don't block — this is async and best-effort
|
|
129
|
+
process.stderr.write(`Forest plan sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// --- Entrypoint ---
|
|
133
|
+
const subcommand = process.argv[2];
|
|
134
|
+
switch (subcommand) {
|
|
135
|
+
case "plan-exit":
|
|
136
|
+
handlePlanExit().catch(() => process.exit(0));
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
console.error(`Unknown hook: ${subcommand}`);
|
|
140
|
+
console.error("Available hooks: plan-exit");
|
|
141
|
+
process.exit(0); // don't block
|
|
142
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP client for Forest backend API.
|
|
3
|
+
* Used by both the MCP server and the hook script.
|
|
4
|
+
*/
|
|
5
|
+
import type { ForestConfig } from "./config.js";
|
|
6
|
+
export declare function forestApi<T>(config: ForestConfig, path: string, options?: {
|
|
7
|
+
method?: string;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
}): Promise<T>;
|
package/build/lib/api.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP client for Forest backend API.
|
|
3
|
+
* Used by both the MCP server and the hook script.
|
|
4
|
+
*/
|
|
5
|
+
export async function forestApi(config, path, options = {}) {
|
|
6
|
+
const url = `${config.apiUrl}/mcp${path}`;
|
|
7
|
+
const response = await fetch(url, {
|
|
8
|
+
method: options.method ?? "GET",
|
|
9
|
+
headers: {
|
|
10
|
+
Authorization: `Bearer ${config.token}`,
|
|
11
|
+
"Content-Type": "application/json",
|
|
12
|
+
},
|
|
13
|
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
const text = await response.text();
|
|
17
|
+
throw new Error(`Forest API error (${response.status}): ${text}`);
|
|
18
|
+
}
|
|
19
|
+
return response.json();
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared config — reads Forest credentials from:
|
|
3
|
+
* 1. Environment variables (FOREST_API_TOKEN, FOREST_API_URL)
|
|
4
|
+
* 2. Config file (~/.config/forest/config.json)
|
|
5
|
+
*
|
|
6
|
+
* The CLI `forest init` writes the config file. Both the MCP server
|
|
7
|
+
* and the hook script use this module to resolve credentials.
|
|
8
|
+
*/
|
|
9
|
+
export interface ForestConfig {
|
|
10
|
+
token: string;
|
|
11
|
+
apiUrl: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function readConfigFile(): Partial<ForestConfig>;
|
|
14
|
+
export declare function writeConfigFile(config: ForestConfig): void;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve config: env vars take priority, then config file.
|
|
17
|
+
* Returns null if no token is found.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveConfig(): ForestConfig | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared config — reads Forest credentials from:
|
|
3
|
+
* 1. Environment variables (FOREST_API_TOKEN, FOREST_API_URL)
|
|
4
|
+
* 2. Config file (~/.config/forest/config.json)
|
|
5
|
+
*
|
|
6
|
+
* The CLI `forest init` writes the config file. Both the MCP server
|
|
7
|
+
* and the hook script use this module to resolve credentials.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join, dirname } from "path";
|
|
12
|
+
const DEFAULT_API_URL = "https://api.forestworks.ai";
|
|
13
|
+
function configPath() {
|
|
14
|
+
return join(homedir(), ".config", "forest", "config.json");
|
|
15
|
+
}
|
|
16
|
+
export function readConfigFile() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(configPath(), "utf-8");
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function writeConfigFile(config) {
|
|
26
|
+
const path = configPath();
|
|
27
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
28
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve config: env vars take priority, then config file.
|
|
32
|
+
* Returns null if no token is found.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveConfig() {
|
|
35
|
+
const file = readConfigFile();
|
|
36
|
+
const token = process.env.FOREST_API_TOKEN || file.token;
|
|
37
|
+
const apiUrl = process.env.FOREST_API_URL || file.apiUrl || DEFAULT_API_URL;
|
|
38
|
+
if (!token)
|
|
39
|
+
return null;
|
|
40
|
+
return { token, apiUrl };
|
|
41
|
+
}
|
package/build/mcp.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forest MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Bridges Claude Code sessions with your team via Forest.
|
|
6
|
+
* Allows syncing conversation context to Slack, viewing tasks,
|
|
7
|
+
* and updating task status — all from within Claude Code.
|
|
8
|
+
*
|
|
9
|
+
* Reads credentials from:
|
|
10
|
+
* 1. Env vars: FOREST_API_TOKEN, FOREST_API_URL
|
|
11
|
+
* 2. Config file: ~/.config/forest/config.json (written by `forest init`)
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
package/build/mcp.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forest MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Bridges Claude Code sessions with your team via Forest.
|
|
6
|
+
* Allows syncing conversation context to Slack, viewing tasks,
|
|
7
|
+
* and updating task status — all from within Claude Code.
|
|
8
|
+
*
|
|
9
|
+
* Reads credentials from:
|
|
10
|
+
* 1. Env vars: FOREST_API_TOKEN, FOREST_API_URL
|
|
11
|
+
* 2. Config file: ~/.config/forest/config.json (written by `forest init`)
|
|
12
|
+
*/
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { resolveConfig } from "./lib/config.js";
|
|
17
|
+
import { forestApi } from "./lib/api.js";
|
|
18
|
+
const config = resolveConfig();
|
|
19
|
+
if (!config) {
|
|
20
|
+
console.error("Forest not configured. Run: npx @co-syn/forest init --token <token>");
|
|
21
|
+
console.error("Or set FOREST_API_TOKEN environment variable.");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
// --- MCP Server ---
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: "forest",
|
|
27
|
+
version: "0.1.0",
|
|
28
|
+
});
|
|
29
|
+
// Tool 1: forest_sync — Share session context with the team via Slack
|
|
30
|
+
server.tool("forest_sync", "Share a summary of your current work session with your team via Slack. " +
|
|
31
|
+
"Use this when the user wants to share what they've been working on, " +
|
|
32
|
+
"communicate decisions, or ping someone about progress. " +
|
|
33
|
+
"You should summarize the relevant parts of the conversation as the summary parameter.", {
|
|
34
|
+
summary: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("A clear, concise summary of what was accomplished, decided, or discussed in this session. " +
|
|
37
|
+
"Include key decisions, code changes, blockers, or questions. " +
|
|
38
|
+
"Write it as if addressing the team — not as a chat transcript."),
|
|
39
|
+
recipients: z
|
|
40
|
+
.array(z.string())
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Slack usernames or display names to @mention in the message (e.g. ['john', 'sarah'])"),
|
|
43
|
+
channel: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Slack channel name to post to (e.g. 'engineering', 'general'). " +
|
|
47
|
+
"If omitted, posts to the team's default channel."),
|
|
48
|
+
taskId: z
|
|
49
|
+
.string()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Forest task ID to link this sync to. If provided, the message will reference the task."),
|
|
52
|
+
}, async (args) => {
|
|
53
|
+
try {
|
|
54
|
+
const result = await forestApi(config, "/sync", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: {
|
|
57
|
+
summary: args.summary,
|
|
58
|
+
recipients: args.recipients,
|
|
59
|
+
channel: args.channel,
|
|
60
|
+
taskId: args.taskId,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const parts = ["Synced to Slack"];
|
|
64
|
+
if (result.channel)
|
|
65
|
+
parts.push(`in #${result.channel}`);
|
|
66
|
+
if (args.recipients?.length)
|
|
67
|
+
parts.push(`(mentioned: ${args.recipients.join(", ")})`);
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: parts.join(" ") }],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return {
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: `Failed to sync: ${error instanceof Error ? error.message : String(error)}`,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
isError: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
// Tool 2: forest_tasks — List active tasks for context
|
|
85
|
+
server.tool("forest_tasks", "List active tasks from Forest for your team. " +
|
|
86
|
+
"Use this to find task IDs when the user wants to link their work to a task, " +
|
|
87
|
+
"or to understand what the team is working on.", {
|
|
88
|
+
goalId: z
|
|
89
|
+
.string()
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Filter tasks by goal ID. If omitted, returns all active tasks."),
|
|
92
|
+
status: z
|
|
93
|
+
.string()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Filter by status (unstarted, in_progress, blocked, at_risk, in_review). " +
|
|
96
|
+
"If omitted, returns all non-completed tasks."),
|
|
97
|
+
}, async (args) => {
|
|
98
|
+
try {
|
|
99
|
+
const params = new URLSearchParams();
|
|
100
|
+
if (args.goalId)
|
|
101
|
+
params.set("goalId", args.goalId);
|
|
102
|
+
if (args.status)
|
|
103
|
+
params.set("status", args.status);
|
|
104
|
+
const query = params.toString();
|
|
105
|
+
const result = await forestApi(config, `/tasks${query ? `?${query}` : ""}`);
|
|
106
|
+
if (result.tasks.length === 0) {
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{ type: "text", text: "No active tasks found." },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const formatted = result.tasks
|
|
114
|
+
.map((t) => `- [${t.status}] ${t.title} (ID: ${t.id})` +
|
|
115
|
+
(t.goalTitle ? `\n Goal: ${t.goalTitle}` : "") +
|
|
116
|
+
(t.assignee ? `\n Assignee: ${t.assignee}` : ""))
|
|
117
|
+
.join("\n\n");
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
text: `Active tasks:\n\n${formatted}`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: "text",
|
|
132
|
+
text: `Failed to fetch tasks: ${error instanceof Error ? error.message : String(error)}`,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
isError: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
// Tool 3: forest_update — Update a task's status or add notes
|
|
140
|
+
server.tool("forest_update", "Update a Forest task with progress, status changes, or notes from your session. " +
|
|
141
|
+
"Use this when the user has completed work on a task or wants to record progress.", {
|
|
142
|
+
taskId: z.string().describe("The Forest task ID to update."),
|
|
143
|
+
status: z
|
|
144
|
+
.string()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("New status: unstarted, in_progress, blocked, at_risk, in_review, done, deferred, dropped"),
|
|
147
|
+
notes: z
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Progress notes to add to the task. Summarize what was done, " +
|
|
151
|
+
"decisions made, or blockers encountered."),
|
|
152
|
+
}, async (args) => {
|
|
153
|
+
try {
|
|
154
|
+
const result = await forestApi(config, `/tasks/${args.taskId}`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: {
|
|
157
|
+
status: args.status,
|
|
158
|
+
notes: args.notes,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const parts = [`Updated task: ${result.task.title}`];
|
|
162
|
+
if (args.status)
|
|
163
|
+
parts.push(`Status → ${result.task.status}`);
|
|
164
|
+
if (args.notes)
|
|
165
|
+
parts.push("Notes added");
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: "text", text: parts.join(". ") }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return {
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: "text",
|
|
175
|
+
text: `Failed to update task: ${error instanceof Error ? error.message : String(error)}`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
isError: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
// --- Start ---
|
|
183
|
+
async function main() {
|
|
184
|
+
const transport = new StdioServerTransport();
|
|
185
|
+
await server.connect(transport);
|
|
186
|
+
console.error("Forest MCP server running on stdio");
|
|
187
|
+
}
|
|
188
|
+
main().catch((err) => {
|
|
189
|
+
console.error("Forest MCP server failed to start:", err);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@co-syn/forest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Forest CLI — sync Claude Code sessions, plans, and tasks with your team",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"forest",
|
|
8
|
+
"mcp",
|
|
9
|
+
"slack",
|
|
10
|
+
"team-sync"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://forestworks.ai",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/co-syn/forest.git",
|
|
16
|
+
"directory": "packages/forest-mcp"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/co-syn/forest/issues"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"forest": "build/cli.js",
|
|
27
|
+
"forest-mcp": "build/mcp.js",
|
|
28
|
+
"forest-hook": "build/hook.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"build"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc && chmod 755 build/cli.js build/mcp.js build/hook.js",
|
|
38
|
+
"dev": "tsx src/mcp.ts",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
43
|
+
"zod": "^3.24.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"tsx": "^4.21.0",
|
|
48
|
+
"typescript": "^5.7.0"
|
|
49
|
+
}
|
|
50
|
+
}
|