import { Injectable, Inject } from '@nestjs/common';
import { Tool, type Context } from '@rekog/mcp-nest';
import type { ConfigType } from '@nestjs/config';
import { z } from 'zod';
import { LogseqClient } from '../logseq/logseq.client';
import { LogseqFormatter } from '../logseq/logseq.formatter';
import { logseqConfig } from '../config/logseq.config';
/**
* Development Tool - 프로젝트 개발 기록 관리
*
* 어떤 프로젝트든 Logseq에서 개발을 추적하고 문서화하기 위한 범용 도구
* 환경변수 PROJECT_NAME으로 프로젝트 페이지 이름 설정 가능
*
* User Flow: Development Recording
* - 개발 진행 상황 기록
* - 기술적 결정 사항 문서화
* - 버그/이슈 추적
* - 아이디어 및 TODO 관리
*/
@Injectable()
export class DevTool {
private readonly projectPage: string;
constructor(
private readonly logseq: LogseqClient,
private readonly formatter: LogseqFormatter,
@Inject(logseqConfig.KEY)
private readonly config: ConfigType<typeof logseqConfig>,
) {
this.projectPage = config.projectName;
}
@Tool({
name: 'dev-log-progress',
description:
'개발 진행 상황을 기록합니다. 구현 완료, 변경 사항, 리팩토링 등을 기록할 때 사용하세요.',
parameters: z.object({
title: z.string().describe('진행 사항 제목 (간단히)'),
description: z.string().describe('상세 설명'),
category: z
.enum(['feature', 'refactor', 'fix', 'docs', 'test', 'chore'])
.describe('카테고리'),
files: z.array(z.string()).optional().describe('관련 파일 목록 (선택)'),
}),
annotations: {
title: '개발 진행 상황 기록',
readOnlyHint: false,
idempotentHint: false,
},
})
async logProgress(
{
title,
description,
category,
files,
}: {
title: string;
description: string;
category: string;
files?: string[];
},
context: Context,
) {
await context.reportProgress({ progress: 10, total: 100 });
const journalName = this.logseq.getTodayJournalName();
const timestamp = new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
});
// 메인 블록 내용
const categoryEmoji = this.getCategoryEmoji(category);
const mainContent = `${categoryEmoji} **[${category.toUpperCase()}]** ${title} #[[${this.projectPage}]] #dev-log`;
await context.reportProgress({ progress: 30, total: 100 });
// 메인 블록 생성
const mainBlock = await this.logseq.appendBlockInPage(
journalName,
mainContent,
);
if (!mainBlock) {
return {
content: [
{
type: 'text',
text: '❌ 개발 로그 기록 실패. Logseq이 실행 중인지 확인해주세요.',
},
],
};
}
await context.reportProgress({ progress: 50, total: 100 });
// 상세 설명 하위 블록
await this.logseq.insertBlock(mainBlock.uuid, description);
// 시간 기록
await this.logseq.insertBlock(mainBlock.uuid, `⏰ ${timestamp}`);
// 파일 목록 하위 블록 (있는 경우)
if (files && files.length > 0) {
const filesContent = `📁 관련 파일:\n${files.map((f) => ` - \`${f}\``).join('\n')}`;
await this.logseq.insertBlock(mainBlock.uuid, filesContent);
}
await context.reportProgress({ progress: 100, total: 100 });
return {
content: [
{
type: 'text',
text: `✅ 개발 로그 기록 완료\n\n📅 ${journalName}\n${categoryEmoji} ${title}\n${description}${files ? `\n\n📁 ${files.length}개 파일 연결됨` : ''}`,
},
],
};
}
@Tool({
name: 'dev-decision',
description:
'기술적 결정 사항(ADR)을 기록합니다. 아키텍처, 라이브러리 선택, 설계 결정 등을 문서화할 때 사용하세요.',
parameters: z.object({
title: z.string().describe('결정 사항 제목'),
context: z.string().describe('결정의 배경/맥락'),
decision: z.string().describe('실제 결정 내용'),
consequences: z.string().describe('결정으로 인한 결과/영향'),
alternatives: z
.array(z.string())
.optional()
.describe('고려했던 대안들 (선택)'),
}),
annotations: {
title: '기술 결정 기록 (ADR)',
readOnlyHint: false,
idempotentHint: false,
},
})
async logDecision(
{
title,
context: decisionContext,
decision,
consequences,
alternatives,
}: {
title: string;
context: string;
decision: string;
consequences: string;
alternatives?: string[];
},
context: Context,
) {
await context.reportProgress({ progress: 10, total: 100 });
const journalName = this.logseq.getTodayJournalName();
const date = new Date().toISOString().split('T')[0];
// ADR 메인 블록
const mainContent = `🏛️ **ADR:** ${title} #[[${this.projectPage}]] #adr`;
await context.reportProgress({ progress: 30, total: 100 });
const mainBlock = await this.logseq.appendBlockInPage(
journalName,
mainContent,
);
if (!mainBlock) {
return {
content: [
{
type: 'text',
text: '❌ ADR 기록 실패. Logseq이 실행 중인지 확인해주세요.',
},
],
};
}
await context.reportProgress({ progress: 50, total: 100 });
// ADR 구조화된 하위 블록들
await this.logseq.insertBlock(mainBlock.uuid, `📅 **날짜:** ${date}`);
await this.logseq.insertBlock(
mainBlock.uuid,
`🔍 **배경:**\n${decisionContext}`,
);
await this.logseq.insertBlock(mainBlock.uuid, `✅ **결정:**\n${decision}`);
await this.logseq.insertBlock(
mainBlock.uuid,
`📊 **결과:**\n${consequences}`,
);
if (alternatives && alternatives.length > 0) {
const altContent = `🔄 **고려한 대안:**\n${alternatives.map((a) => ` - ${a}`).join('\n')}`;
await this.logseq.insertBlock(mainBlock.uuid, altContent);
}
await context.reportProgress({ progress: 100, total: 100 });
return {
content: [
{
type: 'text',
text: `✅ ADR 기록 완료\n\n🏛️ ${title}\n\n**배경:** ${decisionContext.slice(0, 50)}...\n**결정:** ${decision.slice(0, 50)}...`,
},
],
};
}
@Tool({
name: 'dev-todo',
description:
'개발 TODO 항목을 추가합니다. 구현할 기능, 수정할 버그, 개선 사항 등을 기록합니다.',
parameters: z.object({
content: z.string().describe('TODO 내용'),
priority: z
.enum(['high', 'medium', 'low'])
.optional()
.describe('우선순위'),
tags: z.array(z.string()).optional().describe('관련 태그들'),
}),
annotations: {
title: '개발 TODO 추가',
readOnlyHint: false,
idempotentHint: false,
},
})
async addTodo(
{
content,
priority,
tags,
}: {
content: string;
priority?: string;
tags?: string[];
},
context: Context,
) {
await context.reportProgress({ progress: 20, total: 100 });
const journalName = this.logseq.getTodayJournalName();
const priorityMarker = priority ? this.getPriorityMarker(priority) : '';
const tagStr = tags ? tags.map((t) => `#${t}`).join(' ') : '';
const todoContent =
`TODO ${priorityMarker}${content} #[[${this.projectPage}]] ${tagStr}`.trim();
await context.reportProgress({ progress: 50, total: 100 });
const block = await this.logseq.appendBlockInPage(journalName, todoContent);
await context.reportProgress({ progress: 100, total: 100 });
if (!block) {
return {
content: [
{
type: 'text',
text: '❌ TODO 추가 실패. Logseq이 실행 중인지 확인해주세요.',
},
],
};
}
return {
content: [
{
type: 'text',
text: `✅ TODO 추가됨\n\n☐ ${priorityMarker}${content}\n${tags ? `🏷️ ${tags.join(', ')}` : ''}`,
},
],
};
}
@Tool({
name: 'dev-idea',
description:
'개발 아이디어를 기록합니다. 향후 구현할 기능, 개선 아이디어 등을 빠르게 기록할 때 사용하세요.',
parameters: z.object({
idea: z.string().describe('아이디어 내용'),
category: z
.enum(['feature', 'improvement', 'experiment', 'question'])
.optional()
.describe('아이디어 유형'),
}),
annotations: {
title: '개발 아이디어 기록',
readOnlyHint: false,
idempotentHint: false,
},
})
async logIdea(
{
idea,
category,
}: {
idea: string;
category?: string;
},
context: Context,
) {
await context.reportProgress({ progress: 20, total: 100 });
const journalName = this.logseq.getTodayJournalName();
const categoryEmoji = category ? this.getIdeaCategoryEmoji(category) : '💡';
const categoryTag = category ? `#${category}` : '';
const ideaContent =
`${categoryEmoji} **아이디어:** ${idea} #[[${this.projectPage}]] #idea ${categoryTag}`.trim();
await context.reportProgress({ progress: 50, total: 100 });
const block = await this.logseq.appendBlockInPage(journalName, ideaContent);
await context.reportProgress({ progress: 100, total: 100 });
if (!block) {
return {
content: [
{
type: 'text',
text: '❌ 아이디어 기록 실패. Logseq이 실행 중인지 확인해주세요.',
},
],
};
}
return {
content: [
{
type: 'text',
text: `✅ 아이디어 기록됨\n\n${categoryEmoji} ${idea}`,
},
],
};
}
@Tool({
name: 'dev-get-project-logs',
description:
'프로젝트의 개발 로그를 조회합니다. 최근 기록된 진행 상황, 결정 사항, TODO 등을 확인합니다.',
parameters: z.object({
limit: z.number().optional().describe('조회할 최대 항목 수 (기본: 10)'),
}),
annotations: {
title: '프로젝트 개발 로그 조회',
readOnlyHint: true,
idempotentHint: true,
},
})
async getProjectLogs({ limit }: { limit?: number }, context: Context) {
await context.reportProgress({ progress: 20, total: 100 });
const maxItems = limit || 10;
// 프로젝트 페이지의 역참조 조회
const references = await this.logseq.getPageLinkedReferences(
this.projectPage,
);
await context.reportProgress({ progress: 60, total: 100 });
if (!references || references.length === 0) {
return {
content: [
{
type: 'text',
text: `📋 **${this.projectPage}** 관련 개발 로그가 없습니다.\n\n개발 진행 상황을 기록하려면 dev-log-progress 도구를 사용하세요.`,
},
],
};
}
// 블록들을 평탄화하고 정렬
const allBlocks = references.flatMap(([page, blocks]) =>
blocks
.filter((block) => block && block.content) // null 블록 필터링
.map((block) => ({
page: page?.name || page?.['original-name'] || 'Unknown',
content: block.content,
uuid: block.uuid,
})),
);
const recentBlocks = allBlocks.slice(0, maxItems);
await context.reportProgress({ progress: 100, total: 100 });
const logsText = recentBlocks
.map(
(b, i) =>
`${i + 1}. **${b.page}**\n ${b.content.slice(0, 100)}${b.content.length > 100 ? '...' : ''}`,
)
.join('\n\n');
return {
content: [
{
type: 'text',
text: `📋 **${this.projectPage}** 개발 로그 (최근 ${recentBlocks.length}개)\n\n${logsText}`,
},
],
};
}
// ==================== Helper Methods ====================
private getCategoryEmoji(category: string): string {
const emojiMap: Record<string, string> = {
feature: '✨',
refactor: '♻️',
fix: '🐛',
docs: '📝',
test: '🧪',
chore: '🔧',
};
return emojiMap[category] || '📌';
}
private getPriorityMarker(priority: string): string {
const markers: Record<string, string> = {
high: '[#A] ',
medium: '[#B] ',
low: '[#C] ',
};
return markers[priority] || '';
}
private getIdeaCategoryEmoji(category: string): string {
const emojiMap: Record<string, string> = {
feature: '🚀',
improvement: '📈',
experiment: '🧪',
question: '❓',
};
return emojiMap[category] || '💡';
}
}