Skip to content
51 changes: 49 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Slack과 로컬 Claude Code CLI를 연결하는 브리지입니다. AI Assistant
- 채널에서 `@봇이름` 멘션으로 사용 가능 (스레드 답변 + 리액션 상태)
- `repo:이름` 프리픽스로 작업할 레포지토리 지정
- 스레드 대화 컨텍스트 자동 포함 (멀티턴)
- **프롬프트 템플릿** — 글로벌 + 레포별 오버라이드 가능한 커스텀 프롬프트
- **세션 연속성** — Slack에서 시작한 세션을 로컬에서 `claude --resume <id>`로 이어받기
- `--allowedTools`로 MCP 도구 권한 자동 승인
- 인메모리 작업 큐로 동시성 제어
Expand Down Expand Up @@ -48,9 +49,13 @@ Slack과 로컬 Claude Code CLI를 연결하는 브리지입니다. AI Assistant
"taskTimeout": 300000,
"defaultRepo": "my-project",
"claudePath": "/usr/local/bin/claude",
"promptTemplate": "~/.ccslack/prompts/global.txt",
"repos": {
"my-project": "/home/you/projects/my-project",
"frontend": "/home/you/projects/frontend"
"frontend": {
"path": "/home/you/projects/frontend",
"promptTemplate": "~/.ccslack/prompts/frontend.txt"
}
},
"allowedTools": ["Bash", "Read", "Edit", "Glob", "Grep", "Write", "mcp__*"],
"suggestedPrompts": [
Expand Down Expand Up @@ -97,6 +102,47 @@ bun dev
- 프리픽스 이후의 모든 텍스트가 Claude에 전달되는 프롬프트입니다.
- 같은 스레드에서 대화를 이어가면 이전 대화가 컨텍스트로 포함됩니다.

## 프롬프트 템플릿

외부 텍스트 파일로 프롬프트를 커스텀할 수 있습니다. 글로벌 템플릿과 레포별 오버라이드를 지원합니다.

### 변수

| 변수 | 설명 |
|---|---|
| `{{prompt}}` | 사용자 입력 메시지 |
| `{{thread}}` | 스레드 대화 컨텍스트 (없으면 빈 문자열) |
| `{{repo}}` | 레포 이름 |
| `{{global}}` | 글로벌 템플릿 렌더링 결과 (레포별 템플릿에서만 의미) |

### 글로벌 템플릿 예시

`~/.ccslack/prompts/global.txt`:

```
당신은 소프트웨어 엔지니어링 전문가입니다.

{{thread}}

사용자 요청:
{{prompt}}
```

### 레포별 오버라이드 예시

`~/.ccslack/prompts/frontend.txt`:

```
당신은 React/TypeScript 프론트엔드 전문가입니다.
현재 작업 레포: {{repo}}

{{global}}
```

- `{{global}}`을 쓰면 해당 위치에 글로벌 템플릿 렌더링 결과가 삽입됩니다.
- `{{global}}`을 생략하면 글로벌 템플릿은 무시됩니다 (완전 오버라이드).
- 템플릿을 설정하지 않으면 내장 기본 템플릿(`{{thread}}{{prompt}}`)이 사용됩니다.

## 동작 방식

1. AI Assistant 사이드 패널(DM) 또는 채널에서 `@봇이름` 멘션으로 메시지를 보냅니다.
Expand All @@ -112,9 +158,10 @@ bun dev
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `allowedUsers` | `string[]` | (필수) | 사용이 허가된 Slack User ID 목록 |
| `repos` | `Record<string, string>` | (필수) | 레포 단축 이름 → 로컬 경로 매핑 |
| `repos` | `Record<string, string \| object>` | (필수) | 레포 단축 이름 → 경로 또는 `{ path, promptTemplate? }` |
| `defaultRepo` | `string` | — | `repo:` 프리픽스 없을 때 사용할 기본 레포 |
| `claudePath` | `string` | `"claude"` | Claude CLI 바이너리 경로 |
| `promptTemplate` | `string` | 내장 기본값 | 글로벌 프롬프트 템플릿 파일 경로 |
| `maxConcurrency` | `number` | `2` | 최대 동시 Claude 프로세스 수 |
| `taskTimeout` | `number` | `300000` | 작업 타임아웃 (ms, 기본 5분) |
| `allowedTools` | `string[]` | `[]` | 권한 프롬프트 없이 자동 승인할 도구 목록 |
Expand Down
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Bridge your Slack workspace to a local [Claude Code](https://docs.anthropic.com/
- **Channel mentions** — `@your-bot` in any channel for threaded replies with reaction status
- **Multi-repo** — Switch repos with `repo:name` prefix
- **Thread context** — Prior conversation automatically included for multi-turn dialogue
- **Prompt templates** — Customizable prompts with global + per-repo override support
- **Session continuity** — Resume any Slack-initiated session locally with `claude --resume <id>`
- **Tool allowlist** — Auto-approve MCP tools via `--allowedTools`
- **Concurrency control** — In-memory task queue with configurable limits
Expand Down Expand Up @@ -48,9 +49,13 @@ Create `~/.ccslack/config.json`:
"taskTimeout": 300000,
"defaultRepo": "my-project",
"claudePath": "/usr/local/bin/claude",
"promptTemplate": "~/.ccslack/prompts/global.txt",
"repos": {
"my-project": "/home/you/projects/my-project",
"frontend": "/home/you/projects/frontend"
"frontend": {
"path": "/home/you/projects/frontend",
"promptTemplate": "~/.ccslack/prompts/frontend.txt"
}
},
"allowedTools": ["Bash", "Read", "Edit", "Glob", "Grep", "Write", "mcp__*"],
"suggestedPrompts": [
Expand Down Expand Up @@ -97,6 +102,47 @@ There are two ways to interact with the bot:
- Everything after the prefix is the prompt sent to Claude.
- Continuing in the same thread includes prior conversation as context.

## Prompt Templates

Customize prompts via external text files. Supports global templates and per-repo overrides.

### Variables

| Variable | Description |
|---|---|
| `{{prompt}}` | User's input message |
| `{{thread}}` | Thread conversation context (empty string if none) |
| `{{repo}}` | Repo name |
| `{{global}}` | Rendered global template (only meaningful in per-repo templates) |

### Global Template Example

`~/.ccslack/prompts/global.txt`:

```
You are a software engineering expert.

{{thread}}

User request:
{{prompt}}
```

### Per-Repo Override Example

`~/.ccslack/prompts/frontend.txt`:

```
You are a React/TypeScript frontend expert.
Current repo: {{repo}}

{{global}}
```

- Use `{{global}}` to insert the rendered global template at that position.
- Omit `{{global}}` to fully override the global template.
- When no template is configured, the built-in default (`{{thread}}{{prompt}}`) is used.

## How It Works

1. A message arrives via the AI Assistant side panel (DM) or `@mention` in a channel.
Expand All @@ -112,9 +158,10 @@ There are two ways to interact with the bot:
| Field | Type | Default | Description |
|---|---|---|---|
| `allowedUsers` | `string[]` | (required) | Slack User IDs allowed to use the bot |
| `repos` | `Record<string, string>` | (required) | Repo alias → local path mapping |
| `repos` | `Record<string, string \| object>` | (required) | Repo alias → path or `{ path, promptTemplate? }` |
| `defaultRepo` | `string` | — | Default repo when `repo:` prefix is omitted |
| `claudePath` | `string` | `"claude"` | Path to Claude CLI binary |
| `promptTemplate` | `string` | built-in default | Global prompt template file path |
| `maxConcurrency` | `number` | `2` | Max concurrent Claude processes |
| `taskTimeout` | `number` | `300000` | Task timeout in ms (default 5 min) |
| `allowedTools` | `string[]` | `[]` | Tools to auto-approve without permission prompts |
Expand Down
30 changes: 29 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { existsSync, readFileSync } from "node:fs";
import { expandPath } from "./prompt/template";

export interface CCSlackConfig {
allowedUsers: string[];
maxConcurrency: number;
taskTimeout: number;
defaultRepo?: string;
claudePath: string;
repos: Record<string, string>;
repos: Record<string, string | { path: string; promptTemplate?: string }>;
promptTemplate?: string;
allowedTools?: string[];
suggestedPrompts?: Array<{ title: string; message: string }>;
maxOutputTokens?: number;
Expand All @@ -24,6 +26,24 @@ const DEFAULTS: Partial<CCSlackConfig> = {
enableSessionContinuity: true,
};

function validateTemplatePath(filePath: string, label: string): void {
const expanded = expandPath(filePath);
if (!existsSync(expanded)) {
throw new Error(`${label} template file not found: ${expanded}`);
}
}

function validateRepoEntry(name: string, entry: string | { path: string; promptTemplate?: string }): void {
if (typeof entry === "object") {
if (!entry.path) {
throw new Error(`repos.${name}: "path" is required`);
}
if (entry.promptTemplate) {
validateTemplatePath(entry.promptTemplate, `repos.${name}.promptTemplate`);
}
}
}

export function loadConfig(configPath: string): CCSlackConfig {
if (!existsSync(configPath)) {
throw new Error(`Config file not found: ${configPath}`);
Expand All @@ -40,6 +60,14 @@ export function loadConfig(configPath: string): CCSlackConfig {
throw new Error("allowedUsers must contain at least one Slack User ID");
}

if (config.promptTemplate) {
validateTemplatePath(config.promptTemplate, "promptTemplate");
}

for (const [name, entry] of Object.entries(config.repos)) {
validateRepoEntry(name, entry);
}

return config;
}

Expand Down
65 changes: 65 additions & 0 deletions src/prompt/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { CCSlackConfig } from "../config";

const KNOWN_VARS = ["prompt", "thread", "repo", "global"] as const;

const DEFAULT_TEMPLATE_PATH = resolve(import.meta.dir, "../../templates/default.txt");

export function expandPath(filePath: string): string {
if (filePath.startsWith("~/")) {
return (process.env.HOME || "") + filePath.slice(1);
}
return filePath;
}

export function loadTemplate(filePath: string): string {
const expanded = expandPath(filePath);

try {
return readFileSync(expanded, "utf-8");
} catch (err: any) {
if (err.code === "ENOENT") {
throw new Error(`Template file not found: ${expanded}`);
}
throw err;
}
}

export function renderTemplate(template: string, vars: Record<(typeof KNOWN_VARS)[number], string>): string {
let result = template;
for (const key of KNOWN_VARS) {
result = result.replaceAll(`{{${key}}}`, vars[key]);
}
return result;
}

function getRepoConfig(config: CCSlackConfig, repoName: string): { promptTemplate?: string } {
const entry = config.repos[repoName];
if (!entry || typeof entry === "string") return {};
return entry;
}

export function buildPrompt(opts: {
config: CCSlackConfig;
repoName: string;
prompt: string;
threadContext: string;
}): string {
const { config, repoName, prompt, threadContext } = opts;
const repoConfig = getRepoConfig(config, repoName);

const globalTemplatePath = config.promptTemplate ?? DEFAULT_TEMPLATE_PATH;
const globalTemplate = loadTemplate(globalTemplatePath);

const baseVars = { prompt, thread: threadContext, repo: repoName, global: "" };

const renderedGlobal = renderTemplate(globalTemplate, baseVars).trimEnd();

if (repoConfig.promptTemplate) {
const repoTemplate = loadTemplate(repoConfig.promptTemplate);
return renderTemplate(repoTemplate, { ...baseVars, global: renderedGlobal }).trimEnd();
}

return renderedGlobal;
}
Loading