jutell 0.3.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/LICENSE +21 -0
- package/README.md +11 -0
- package/assets/default-config.json +26 -0
- package/assets/local-admin/assets/index-CVml-p-C.css +1 -0
- package/assets/local-admin/assets/index-Gxd8X8ii.js +60 -0
- package/assets/local-admin/index.html +14 -0
- package/assets/local-admin-server.js +1391 -0
- package/assets/mcp-server/config/bridge-config.js +72 -0
- package/assets/mcp-server/index.js +42 -0
- package/assets/mcp-server/tools/bridge-tools.js +64 -0
- package/assets/mcp-server/tools/catalog.js +25 -0
- package/assets/mcp-server/tools/usage-counters.js +97 -0
- package/assets/skill/SKILL.md +135 -0
- package/assets/skill/references/explained-diff-format.md +117 -0
- package/assets/skill/references/feature-registry.md +29 -0
- package/assets/skill/references/glossary-ko.md +302 -0
- package/assets/skill/references/report-format.md +176 -0
- package/assets/skill/references/risk-level-guide.md +85 -0
- package/assets/templates/request-builder/BUG_REPORT_REQUEST.md +100 -0
- package/assets/templates/request-builder/CODE_REVIEW_REQUEST.md +93 -0
- package/assets/templates/request-builder/DESIGN_REQUEST.md +111 -0
- package/assets/templates/request-builder/FEATURE_REQUEST.md +93 -0
- package/assets/templates/request-builder/MANUAL_EDIT_GUIDE.md +95 -0
- package/assets/templates/request-builder/NEXT_AGENT_HANDOFF.md +106 -0
- package/assets/templates/request-builder/PROJECT_PLANNING_REQUEST.md +106 -0
- package/assets/templates/request-builder/README.md +48 -0
- package/assets/version.json +6 -0
- package/dist/cli.js +82 -0
- package/dist/commands/dashboard.js +81 -0
- package/dist/commands/default.js +103 -0
- package/dist/commands/lifecycle.js +166 -0
- package/dist/commands/migrate.js +159 -0
- package/dist/commands/provider.js +135 -0
- package/dist/commands/session/add-work.js +43 -0
- package/dist/commands/session/create-page.js +53 -0
- package/dist/commands/session/finish-session.js +28 -0
- package/dist/commands/session/index.js +76 -0
- package/dist/commands/session/move-page.js +37 -0
- package/dist/commands/session/new-session.js +24 -0
- package/dist/commands/session/operator-storage.js +126 -0
- package/dist/commands/session/prompt.js +77 -0
- package/dist/commands/session/storage-command.js +74 -0
- package/dist/commands/session/storage.js +212 -0
- package/dist/commands/session/types.js +1 -0
- package/dist/commands/status.js +208 -0
- package/dist/commands/upgrade.js +113 -0
- package/dist/commands/use.js +180 -0
- package/dist/compat.js +5 -0
- package/dist/config/managed.js +257 -0
- package/dist/config/paths.js +100 -0
- package/dist/index.js +4 -0
- package/dist/installer/agents.js +42 -0
- package/dist/installer/claude.js +160 -0
- package/dist/installer/config.js +45 -0
- package/dist/installer/opencode.js +237 -0
- package/dist/installer/providers.js +15 -0
- package/dist/installer/skill.js +94 -0
- package/dist/output/format.js +187 -0
- package/dist/process/mcpProbe.js +122 -0
- package/dist/process/system.js +34 -0
- package/dist/types.js +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readBridgeConfig, readCodexRegistration } from '../config/managed.js';
|
|
2
|
+
import { AGENT_PROVIDERS, findProvider } from '../installer/providers.js';
|
|
3
|
+
import { opencodeDetected, readOpenCodeRegistration, registerOpenCodeMcp, setOpenCodeEnabled } from '../installer/opencode.js';
|
|
4
|
+
import { readClaudeRegistration } from '../installer/claude.js';
|
|
5
|
+
import { setMcpDisabled, setMcpEnabled } from '../installer/config.js';
|
|
6
|
+
import { claudeDetected, codexDetected } from '../process/system.js';
|
|
7
|
+
import { codexScopedPaths, packageRoot } from '../config/paths.js';
|
|
8
|
+
export async function readProviderStatuses(paths) {
|
|
9
|
+
// Codex only reads its global config (see codexScopedPaths), regardless
|
|
10
|
+
// of this invocation's --project/--global scope.
|
|
11
|
+
const codex = await readCodexRegistration(codexScopedPaths(paths), packageRoot(), false);
|
|
12
|
+
const opencode = await readOpenCodeRegistration(paths, packageRoot(), false);
|
|
13
|
+
const claude = await readClaudeRegistration(paths, packageRoot(), false);
|
|
14
|
+
return AGENT_PROVIDERS.map((provider) => {
|
|
15
|
+
if (provider.status === 'planned')
|
|
16
|
+
return { provider, detected: false, registered: false, conflict: false, enabled: false };
|
|
17
|
+
if (provider.id === 'codex')
|
|
18
|
+
return { provider, detected: codexDetected(), registered: codex.registered, conflict: codex.conflict, enabled: codex.enabled };
|
|
19
|
+
if (provider.id === 'claude-code')
|
|
20
|
+
return { provider, detected: claudeDetected(), registered: claude.registered, conflict: claude.conflict, enabled: claude.enabled };
|
|
21
|
+
return { provider, detected: opencodeDetected(), registered: opencode.registered, conflict: opencode.conflict, enabled: opencode.enabled };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function providerLabel(id) {
|
|
25
|
+
const provider = findProvider(id);
|
|
26
|
+
return provider ? `${provider.label} (${provider.status === 'supported' ? '현재 지원' : provider.status === 'beta' ? '베타' : '확장 준비'})` : id;
|
|
27
|
+
}
|
|
28
|
+
function openCodeScopeLabel(paths) {
|
|
29
|
+
return paths.scope === 'global' ? '사용자 OpenCode 설정' : '현재 프로젝트/opencode.json';
|
|
30
|
+
}
|
|
31
|
+
function claudeScopeLabel(paths) {
|
|
32
|
+
return paths.scope === 'global' ? '사용자 전역 Claude 설정 (user)' : '현재 프로젝트 Claude 설정 (local, 공유되지 않음)';
|
|
33
|
+
}
|
|
34
|
+
async function listCommand(io) {
|
|
35
|
+
io.write('JuTell AI Agent Provider 목록');
|
|
36
|
+
for (const provider of AGENT_PROVIDERS) {
|
|
37
|
+
const state = provider.status === 'supported' ? '현재 지원' : provider.status === 'beta' ? '베타' : '확장 준비';
|
|
38
|
+
io.write(`${provider.label.padEnd(10)} ${state}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function summaryCommand(paths, io) {
|
|
42
|
+
const statuses = await readProviderStatuses(paths);
|
|
43
|
+
const config = await readBridgeConfig(paths);
|
|
44
|
+
const active = statuses.filter((item) => item.registered && item.enabled).map((item) => item.provider.label);
|
|
45
|
+
io.write('JuTell Agent 연결 상태\n');
|
|
46
|
+
for (const item of statuses) {
|
|
47
|
+
const state = item.provider.status === 'planned' ? '준비 중' : item.conflict ? '설정 충돌' : item.registered ? (item.enabled ? '연결됨 · 활성' : '연결됨 · 비활성') : '연결 안 됨';
|
|
48
|
+
io.write(`${item.provider.label.padEnd(12)} ${state}`);
|
|
49
|
+
}
|
|
50
|
+
io.write(`\n현재 권장 Agent: ${active.length ? active.join(', ') : '없음'}`);
|
|
51
|
+
io.write(`JuTell 자동 적용: ${config.config.mcp?.enabled ? '켜짐' : '꺼짐'}`);
|
|
52
|
+
if (config.config.mcp?.enabled && active.length === 0)
|
|
53
|
+
io.write('주의: 연결 정책은 켜져 있지만 자동 시작할 활성 Provider 항목이 없습니다. jutell use <agent> 를 실행하거나 provider enable 을 사용해 주세요.');
|
|
54
|
+
else if (!config.config.mcp?.enabled && active.length > 0)
|
|
55
|
+
io.write('주의: 연결 정책은 꺼져 있지만 Provider 자동 시작이 켜져 있습니다. 일치시키려면 jutell use <agent> 또는 jutell off 을 실행해 주세요.');
|
|
56
|
+
}
|
|
57
|
+
async function statusCommand(paths, io) {
|
|
58
|
+
const codex = await readCodexRegistration(codexScopedPaths(paths), packageRoot(), false);
|
|
59
|
+
const opencode = await readOpenCodeRegistration(paths, packageRoot(), false);
|
|
60
|
+
const claude = await readClaudeRegistration(paths, packageRoot(), false);
|
|
61
|
+
io.write(`JuTell AI Agent Provider 상태
|
|
62
|
+
|
|
63
|
+
${providerLabel('codex')}
|
|
64
|
+
감지: ${codexDetected() ? '예' : '직접 확인 필요'}
|
|
65
|
+
설정 파일: 사용자 전역 Codex 설정 (Codex는 프로젝트 설정을 읽지 않음)
|
|
66
|
+
MCP 등록: ${codex.registered ? '등록됨' : codex.conflict ? '충돌 확인 필요' : '등록되지 않음'}
|
|
67
|
+
활성화: ${codex.registered ? (codex.enabled ? '예' : '아니오') : '등록되지 않음'}
|
|
68
|
+
|
|
69
|
+
${providerLabel('opencode')}
|
|
70
|
+
감지: ${opencodeDetected() ? '예' : '직접 확인 필요'}
|
|
71
|
+
설정 파일: ${openCodeScopeLabel(paths)}
|
|
72
|
+
MCP 등록: ${opencode.registered ? '등록됨' : opencode.conflict ? '충돌 확인 필요' : '등록되지 않음'}
|
|
73
|
+
활성화: ${opencode.registered ? (opencode.enabled ? '예' : '아니오') : '등록되지 않음'}
|
|
74
|
+
|
|
75
|
+
${providerLabel('claude-code')}
|
|
76
|
+
감지: ${claudeDetected() ? '예' : '직접 확인 필요'}
|
|
77
|
+
설정 파일: ${claudeScopeLabel(paths)}
|
|
78
|
+
MCP 등록: ${claude.registered ? '등록됨' : '등록되지 않음'}
|
|
79
|
+
활성화: ${claude.registered ? '예' : '등록되지 않음'}`);
|
|
80
|
+
}
|
|
81
|
+
function requireTarget(args) {
|
|
82
|
+
const target = args[1];
|
|
83
|
+
if (!target)
|
|
84
|
+
throw new Error('설정할 Provider 이름이 필요합니다. 예: jutell provider setup opencode');
|
|
85
|
+
if (target === 'codex')
|
|
86
|
+
throw new Error('Codex는 기존 jutell setup / on / off 또는 jutell use codex 명령을 사용하세요.');
|
|
87
|
+
if (target === 'claude' || target === 'claude-code')
|
|
88
|
+
throw new Error('Claude Code는 jutell use claude 명령을 사용하세요.');
|
|
89
|
+
if (target !== 'opencode')
|
|
90
|
+
throw new Error('provider 설정 명령은 opencode만 지원합니다. 일반 연결은 jutell use <agent> 를 사용하세요.');
|
|
91
|
+
}
|
|
92
|
+
export async function providerCommand(paths, options, io, args) {
|
|
93
|
+
const sub = args[0];
|
|
94
|
+
if (!sub)
|
|
95
|
+
return summaryCommand(paths, io);
|
|
96
|
+
if (sub === 'list')
|
|
97
|
+
return listCommand(io);
|
|
98
|
+
if (sub === 'status')
|
|
99
|
+
return statusCommand(paths, io);
|
|
100
|
+
if (sub === 'setup') {
|
|
101
|
+
requireTarget(args);
|
|
102
|
+
const policy = (await readBridgeConfig(paths)).config.mcp?.enabled === true;
|
|
103
|
+
const current = await readOpenCodeRegistration(paths, packageRoot(), policy);
|
|
104
|
+
if (current.conflict)
|
|
105
|
+
throw new Error('OpenCode 설정에 같은 이름의 관리되지 않는 MCP 항목이 있어 자동 변경하지 않았습니다.');
|
|
106
|
+
io.write(`JuTell OpenCode 연결 설정 미리보기\n\n설정 파일: ${openCodeScopeLabel(paths)}\n상태: ${current.registered ? '이미 등록됨 (최신 설정으로 갱신)' : '새로 등록'}\n새 세션 자동 시작: 연결 정책(.jutell.json) 기준 (현재 ${policy ? '켜짐' : '꺼짐'})\n\n${current.preview}`);
|
|
107
|
+
if (!options.yes && !(await io.ask('위 변경을 진행할까요?')))
|
|
108
|
+
return { cancelled: true };
|
|
109
|
+
await registerOpenCodeMcp(paths, packageRoot(), policy);
|
|
110
|
+
io.write('OpenCode 연결 설정을 등록했습니다. 기존 설정은 보존했고 JuTell 관리 블록만 추가했습니다.');
|
|
111
|
+
return { cancelled: false };
|
|
112
|
+
}
|
|
113
|
+
if (sub === 'enable') {
|
|
114
|
+
requireTarget(args);
|
|
115
|
+
await registerOpenCodeMcp(paths, packageRoot(), true);
|
|
116
|
+
await setMcpEnabled(paths, true);
|
|
117
|
+
io.write('OpenCode JuTell MCP를 활성화했습니다. 연결 정책을 켜고 새 OpenCode 세션부터 자동으로 사용할 수 있습니다.');
|
|
118
|
+
return { cancelled: false };
|
|
119
|
+
}
|
|
120
|
+
if (sub === 'disable') {
|
|
121
|
+
requireTarget(args);
|
|
122
|
+
const current = await readOpenCodeRegistration(paths, packageRoot(), false);
|
|
123
|
+
if (!current.registered) {
|
|
124
|
+
io.write('등록된 OpenCode JuTell 관리 블록이 없습니다. 먼저 jutell provider setup opencode 를 실행하세요.');
|
|
125
|
+
return { cancelled: false };
|
|
126
|
+
}
|
|
127
|
+
await setOpenCodeEnabled(paths, packageRoot(), false);
|
|
128
|
+
const codex = await readCodexRegistration(codexScopedPaths(paths), packageRoot(), false);
|
|
129
|
+
if (!codex.enabled)
|
|
130
|
+
await setMcpDisabled(paths);
|
|
131
|
+
io.write('OpenCode JuTell MCP를 비활성화했습니다. 설정 항목은 유지됩니다.');
|
|
132
|
+
return { cancelled: false };
|
|
133
|
+
}
|
|
134
|
+
throw new Error('provider 하위 명령: list, status, setup, enable, disable');
|
|
135
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { todayStamp, readSessionMeta, nextWorkNumber, workTemplate, pageLabel, listPageFiles, pad2, } from './storage.js';
|
|
4
|
+
import { resolveSessionRoot } from './operator-storage.js';
|
|
5
|
+
import { arrowList } from './prompt.js';
|
|
6
|
+
export async function addWorkCommand(paths, options, io) {
|
|
7
|
+
const { root } = await resolveSessionRoot(paths);
|
|
8
|
+
const stamp = todayStamp();
|
|
9
|
+
const dir = path.join(root, stamp);
|
|
10
|
+
const meta = await readSessionMeta(dir);
|
|
11
|
+
if (!meta)
|
|
12
|
+
throw new Error('오늘 Session이 없습니다. `jutell session new`로 시작하세요.');
|
|
13
|
+
let page = meta.pages.find((p) => p.number === (options.page ?? meta.currentPage));
|
|
14
|
+
if (!page) {
|
|
15
|
+
if (meta.pages.length === 0)
|
|
16
|
+
throw new Error('Page가 없습니다. `jutell session page`로 먼저 만들거나 `--page <번호>`를 지정하세요.');
|
|
17
|
+
if (options.page)
|
|
18
|
+
throw new Error(`Page ${options.page}을(를) 찾지 못했습니다.`);
|
|
19
|
+
const files = await listPageFiles(dir);
|
|
20
|
+
const picks = files.map((f, index) => ({
|
|
21
|
+
label: `${pageLabel(meta.pages[index] ?? f)} — ${f.number}`,
|
|
22
|
+
}));
|
|
23
|
+
const picked = await arrowList('작업을 추가할 Page 선택:', picks.map((p) => p.label), Math.max(0, (meta.currentPage ?? 1) - 1));
|
|
24
|
+
if (picked === null || picked === -1)
|
|
25
|
+
throw new Error('Page 선택이 필요합니다.');
|
|
26
|
+
page = meta.pages[picked] ?? null;
|
|
27
|
+
if (!page)
|
|
28
|
+
throw new Error('Page 목록을 읽지 못했습니다.');
|
|
29
|
+
}
|
|
30
|
+
const filePath = path.join(dir, page.file);
|
|
31
|
+
let content;
|
|
32
|
+
try {
|
|
33
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`Page 파일을 읽지 못했습니다: ${page.file}`);
|
|
37
|
+
}
|
|
38
|
+
const workNumber = nextWorkNumber(content);
|
|
39
|
+
content = `${content.replace(/\s+$/, '')}\n\n${workTemplate(workNumber)}\n`;
|
|
40
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
41
|
+
io.write(`작업 ${pad2(workNumber)} 추가 완료: ${pageLabel(page)} (작업 번호는 Page마다 독립)`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { todayStamp, readSessionMeta, saveSessionMeta, pageFileName, pageTemplate, pageLabel, } from './storage.js';
|
|
4
|
+
import { resolveSessionRoot } from './operator-storage.js';
|
|
5
|
+
import { askText, arrowList } from './prompt.js';
|
|
6
|
+
const AGENT_CHOICES = ['OpenCode', 'Codex', 'Claude Code', 'Cline'];
|
|
7
|
+
export async function createPageCommand(paths, options, io) {
|
|
8
|
+
const { root } = await resolveSessionRoot(paths);
|
|
9
|
+
const stamp = todayStamp();
|
|
10
|
+
const dir = path.join(root, stamp);
|
|
11
|
+
const meta = await readSessionMeta(dir);
|
|
12
|
+
if (!meta) {
|
|
13
|
+
throw new Error('오늘 Session이 없습니다. `jutell session new`로 시작하세요.');
|
|
14
|
+
}
|
|
15
|
+
if (meta.status === 'finished') {
|
|
16
|
+
throw new Error('오늘 Session은 마감되었습니다. Page는 더 추가하지 않습니다.');
|
|
17
|
+
}
|
|
18
|
+
let agent = options.agent;
|
|
19
|
+
if (!agent) {
|
|
20
|
+
const picked = await arrowList('사용 Agent 선택:', AGENT_CHOICES);
|
|
21
|
+
if (picked === null || picked === -1)
|
|
22
|
+
throw new Error('Agent 선택이 필요합니다. `--agent <이름>`을 지정할 수도 있습니다.');
|
|
23
|
+
agent = AGENT_CHOICES[picked];
|
|
24
|
+
}
|
|
25
|
+
let role = options.role ?? (await askText('집중할 역할:'));
|
|
26
|
+
if (!role)
|
|
27
|
+
throw new Error('역할 입력이 필요합니다. `--role <역할>`을 지정할 수도 있습니다.');
|
|
28
|
+
let title = options.title ?? (await askText('Page 제목:'));
|
|
29
|
+
if (!title)
|
|
30
|
+
throw new Error('제목 입력이 필요합니다. `--title <제목>`을 지정할 수도 있습니다.');
|
|
31
|
+
const number = (meta.pages.reduce((max, page) => Math.max(max, page.number), 0)) + 1;
|
|
32
|
+
const page = { number, agent, role, title, file: pageFileName({ number, agent, title }) };
|
|
33
|
+
const filePath = path.join(dir, page.file);
|
|
34
|
+
if (await exists(filePath)) {
|
|
35
|
+
throw new Error(`Page 파일이 이미 있습니다. 덮어쓰지 않습니다: ${page.file}`);
|
|
36
|
+
}
|
|
37
|
+
await fs.writeFile(filePath, pageTemplate(page, stamp), 'utf8');
|
|
38
|
+
meta.pages.push(page);
|
|
39
|
+
meta.currentPage = number;
|
|
40
|
+
await saveSessionMeta(dir, meta);
|
|
41
|
+
io.write(`Page 추가 완료: ${pageLabel(page)}`);
|
|
42
|
+
io.write(`파일: ${page.file} (작업 01이 함께 생성됩니다)`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
async function exists(file) {
|
|
46
|
+
try {
|
|
47
|
+
await fs.access(file);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { todayStamp, readSessionMeta, saveSessionMeta, summaryTemplate, SESSION_SUMMARY_FILE } from './storage.js';
|
|
4
|
+
import { resolveSessionRoot } from './operator-storage.js';
|
|
5
|
+
export async function finishSessionCommand(paths, io) {
|
|
6
|
+
const { root } = await resolveSessionRoot(paths);
|
|
7
|
+
const stamp = todayStamp();
|
|
8
|
+
const dir = path.join(root, stamp);
|
|
9
|
+
const meta = await readSessionMeta(dir);
|
|
10
|
+
if (!meta)
|
|
11
|
+
throw new Error('오늘 Session이 없습니다. `jutell session new`로 시작하세요.');
|
|
12
|
+
if (meta.status === 'finished') {
|
|
13
|
+
io.write('오늘 Session은 이미 마감되어 있습니다. SESSION_SUMMARY.md를 다시 만들지 않았습니다.');
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const summaryPath = path.join(dir, SESSION_SUMMARY_FILE);
|
|
17
|
+
try {
|
|
18
|
+
await fs.access(summaryPath);
|
|
19
|
+
io.write('SESSION_SUMMARY.md가 이미 있습니다. 덮어쓰지 않았습니다.');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
await fs.writeFile(summaryPath, `${summaryTemplate(stamp, meta.pages)}\n`, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
meta.status = 'finished';
|
|
25
|
+
await saveSessionMeta(dir, meta);
|
|
26
|
+
io.write(`Session 마감 완료: ${stamp} → SESSION_SUMMARY.md 생성`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { todayStamp, readSessionMeta, listLegacyFlatFiles, pageLabel } from './storage.js';
|
|
3
|
+
import { resolveSessionRoot } from './operator-storage.js';
|
|
4
|
+
import { createSessionCommand } from './new-session.js';
|
|
5
|
+
import { createPageCommand } from './create-page.js';
|
|
6
|
+
import { addWorkCommand } from './add-work.js';
|
|
7
|
+
import { movePageCommand } from './move-page.js';
|
|
8
|
+
import { finishSessionCommand } from './finish-session.js';
|
|
9
|
+
import { storageCommand } from './storage-command.js';
|
|
10
|
+
export async function sessionStatusCommand(paths, io) {
|
|
11
|
+
const { root } = await resolveSessionRoot(paths);
|
|
12
|
+
const stamp = todayStamp();
|
|
13
|
+
const meta = await readSessionMeta(path.join(root, stamp));
|
|
14
|
+
if (!meta) {
|
|
15
|
+
const legacy = await listLegacyFlatFiles(root, stamp);
|
|
16
|
+
if (legacy.length > 0) {
|
|
17
|
+
io.write(`오늘 Session 폴더가 아직 없습니다. (레거시 단일 파일 ${legacy.length}개는 그대로 두고 있습니다.)`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
io.write('오늘 Session이 없습니다. `jutell session new`로 시작하세요.');
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
const current = meta.pages.find((page) => page.number === meta.currentPage) ?? null;
|
|
25
|
+
io.write(`Session: ${meta.date} — ${meta.status === 'finished' ? '마감됨' : '진행 중'}`);
|
|
26
|
+
io.write(`현재 Page: ${current ? pageLabel(current) : '(없음)'}`);
|
|
27
|
+
if (meta.pages.length === 0) {
|
|
28
|
+
io.write('Page가 아직 없습니다. `jutell session page`로 첫 Page를 추가하세요.');
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
for (const page of meta.pages) {
|
|
32
|
+
const marker = page.number === meta.currentPage ? ' (현재)' : '';
|
|
33
|
+
io.write(`- ${pageLabel(page)}${marker}`);
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
export async function sessionHelpCommand(io) {
|
|
38
|
+
io.write(`Session은 하루 작업 기록을 담는 폴더입니다.
|
|
39
|
+
Page는 Agent·역할별 파일, Work는 Page 안의 작업입니다.
|
|
40
|
+
|
|
41
|
+
하루 기록 명령
|
|
42
|
+
|
|
43
|
+
jutell session 오늘 기록 상태를 봅니다.
|
|
44
|
+
jutell session new 오늘 Session 폴더를 만듭니다.
|
|
45
|
+
jutell session page Agent·역할별 Page 파일을 만듭니다.
|
|
46
|
+
jutell session work 현재 Page에 다음 번호 작업을 추가합니다.
|
|
47
|
+
jutell session move 작업할 Page를 이동합니다.
|
|
48
|
+
jutell session finish SESSION_SUMMARY.md로 하루를 마감합니다.
|
|
49
|
+
|
|
50
|
+
운영자용 (보통 사용할 필요가 없습니다)
|
|
51
|
+
|
|
52
|
+
jutell session storage Session 저장 위치 상태를 확인합니다.
|
|
53
|
+
jutell session storage set <절대 경로> Session 저장 위치를 운영자 지정으로 바꿉니다.
|
|
54
|
+
jutell session storage reset 운영자 지정을 제거하고 기본 위치로 돌아갑니다.`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
export async function sessionCommand(paths, options, io, extraArgs) {
|
|
58
|
+
const sub = extraArgs[0] ?? '';
|
|
59
|
+
if (sub === 'new')
|
|
60
|
+
return createSessionCommand(paths, io);
|
|
61
|
+
if (sub === 'page')
|
|
62
|
+
return createPageCommand(paths, options, io);
|
|
63
|
+
if (sub === 'work')
|
|
64
|
+
return addWorkCommand(paths, options, io);
|
|
65
|
+
if (sub === 'move')
|
|
66
|
+
return movePageCommand(paths, options, io);
|
|
67
|
+
if (sub === 'finish')
|
|
68
|
+
return finishSessionCommand(paths, io);
|
|
69
|
+
if (sub === 'storage')
|
|
70
|
+
return storageCommand(paths, options, io, extraArgs.slice(1));
|
|
71
|
+
if (sub === 'help' || sub === '-h' || sub === '--help')
|
|
72
|
+
return sessionHelpCommand(io);
|
|
73
|
+
if (sub === '')
|
|
74
|
+
return sessionStatusCommand(paths, io);
|
|
75
|
+
throw new Error(`알 수 없는 session 하위 명령입니다: ${sub}\n` + `가능한 명령: ${sub} help를 실행해 확인하세요.`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { todayStamp, readSessionMeta, saveSessionMeta, pageLabel, listPageFiles, } from './storage.js';
|
|
3
|
+
import { resolveSessionRoot } from './operator-storage.js';
|
|
4
|
+
import { arrowList } from './prompt.js';
|
|
5
|
+
export async function movePageCommand(paths, options, io) {
|
|
6
|
+
const { root } = await resolveSessionRoot(paths);
|
|
7
|
+
const stamp = todayStamp();
|
|
8
|
+
const dir = path.join(root, stamp);
|
|
9
|
+
const meta = await readSessionMeta(dir);
|
|
10
|
+
if (!meta)
|
|
11
|
+
throw new Error('오늘 Session이 없습니다. `jutell session new`로 시작하세요.');
|
|
12
|
+
if (meta.pages.length === 0)
|
|
13
|
+
throw new Error('Page가 없습니다. `jutell session page`로 먼저 만들세요.`');
|
|
14
|
+
const files = await listPageFiles(dir);
|
|
15
|
+
if (options.page) {
|
|
16
|
+
const target = meta.pages.find((p) => p.number === options.page);
|
|
17
|
+
if (!target)
|
|
18
|
+
throw new Error(`Page ${options.page}을(를) 찾지 못했습니다.`);
|
|
19
|
+
meta.currentPage = options.page;
|
|
20
|
+
await saveSessionMeta(dir, meta);
|
|
21
|
+
io.write(`현재 Page 변경: ${pageLabel(target)}`);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
const labels = files.map((f, index) => pageLabel(meta.pages[index] ?? f));
|
|
25
|
+
const defaultIndex = Math.max(0, (meta.currentPage ?? 1) - 1);
|
|
26
|
+
const picked = await arrowList('이동할 Page 선택:', labels, defaultIndex);
|
|
27
|
+
if (picked === null || picked === -1) {
|
|
28
|
+
throw new Error('Page 선택이 필요합니다. `--page <번호>`를 지정할 수도 있습니다.');
|
|
29
|
+
}
|
|
30
|
+
const target = meta.pages[picked] ?? null;
|
|
31
|
+
if (!target)
|
|
32
|
+
throw new Error('Page 목록을 읽지 못했습니다.');
|
|
33
|
+
meta.currentPage = target.number;
|
|
34
|
+
await saveSessionMeta(dir, meta);
|
|
35
|
+
io.write(`Page 이동 완료: ${pageLabel(target)}`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { todayStamp, readSessionMeta, createInitialMeta, saveSessionMeta, listLegacyFlatFiles } from './storage.js';
|
|
4
|
+
import { resolveSessionRoot } from './operator-storage.js';
|
|
5
|
+
export async function createSessionCommand(paths, io) {
|
|
6
|
+
const { root } = await resolveSessionRoot(paths);
|
|
7
|
+
const stamp = todayStamp();
|
|
8
|
+
const dir = path.join(root, stamp);
|
|
9
|
+
const meta = await readSessionMeta(dir);
|
|
10
|
+
if (meta) {
|
|
11
|
+
io.write(`오늘 Session이 이미 있습니다: ${stamp} — ${meta.status === 'finished' ? '마감됨' : '진행 중'}`);
|
|
12
|
+
io.write('기존 Session을 이어서 사용하세요.');
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
await fs.mkdir(dir, { recursive: true });
|
|
16
|
+
await saveSessionMeta(dir, createInitialMeta(stamp));
|
|
17
|
+
const legacy = await listLegacyFlatFiles(root, stamp);
|
|
18
|
+
if (legacy.length > 0) {
|
|
19
|
+
io.write(`레거시 단일 파일 ${legacy.length}개를 발견했지만 자동 변환하지 않고 그대로 둡니다.`);
|
|
20
|
+
}
|
|
21
|
+
io.write(`Session 생성 완료: ${stamp}`);
|
|
22
|
+
io.write('첫 Page를 추가하려면 `jutell session page`를 실행하세요.');
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sessionRoot } from './storage.js';
|
|
4
|
+
// 운영자 로컬 설정 파일. 공개 Git에 포함되지 않으며(.gitignore),
|
|
5
|
+
// Review Bundle·check:public에서도 제외·탐지 대상이다.
|
|
6
|
+
export const OPERATOR_STORAGE_CONFIG_FILE = '.jutell-operator.local.json';
|
|
7
|
+
// 경로가 잘못되었을 때 사용자에게 보여주는 안내. 절대 경로를 포함하지 않는다.
|
|
8
|
+
export const OPERATOR_STORAGE_ERROR = '운영자 Session 저장 위치를 사용할 수 없습니다.\n' +
|
|
9
|
+
'설정을 확인하거나 제거하면 기본 로컬 저장 위치를 사용할 수 있습니다.';
|
|
10
|
+
export function operatorStorageConfigFile(targetRoot) {
|
|
11
|
+
return path.join(targetRoot, OPERATOR_STORAGE_CONFIG_FILE);
|
|
12
|
+
}
|
|
13
|
+
// 설정 파일을 읽고 sessionStoragePath를 돌려준다.
|
|
14
|
+
// - 파일이 없으면 undefined (기본 저장 사용)
|
|
15
|
+
// - JSON이 손상되었거나 형식이 틀리면 OPERATOR_STORAGE_ERROR를 던진다
|
|
16
|
+
// - sessionStoragePath가 없으면 undefined
|
|
17
|
+
// - sessionStoragePath가 있지만 올바른 절대 경로가 아니면 OPERATOR_STORAGE_ERROR를 던진다
|
|
18
|
+
// (잘못된 경로를 기본 저장으로 조용히 대체하지 않는다)
|
|
19
|
+
export async function readOperatorStoragePath(targetRoot) {
|
|
20
|
+
const file = operatorStorageConfigFile(targetRoot);
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = await fs.readFile(file, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(OPERATOR_STORAGE_ERROR);
|
|
34
|
+
}
|
|
35
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
36
|
+
throw new Error(OPERATOR_STORAGE_ERROR);
|
|
37
|
+
}
|
|
38
|
+
const value = parsed.sessionStoragePath;
|
|
39
|
+
if (value === undefined || value === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
42
|
+
throw new Error(OPERATOR_STORAGE_ERROR);
|
|
43
|
+
const candidate = value.trim();
|
|
44
|
+
if (!path.isAbsolute(candidate))
|
|
45
|
+
throw new Error(OPERATOR_STORAGE_ERROR);
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
// Session 명령이 실제 사용할 저장 루트를 결정한다. 폴더를 만들지 않는다.
|
|
49
|
+
// 운영자 설정이 있고 사용 가능하면 그 경로를, 없으면 기본 .jutell-local 경로를 돌려준다.
|
|
50
|
+
// 설정된 경로가 폴더가 아니거나 접근할 수 없으면 중단한다 (fallback 없음).
|
|
51
|
+
export async function resolveSessionRoot(paths) {
|
|
52
|
+
const custom = await readOperatorStoragePath(paths.targetRoot);
|
|
53
|
+
if (custom === undefined) {
|
|
54
|
+
return { kind: 'default', root: sessionRoot(paths.dataRoot) };
|
|
55
|
+
}
|
|
56
|
+
const problem = await inspectDir(custom);
|
|
57
|
+
if (problem !== undefined)
|
|
58
|
+
throw new Error(OPERATOR_STORAGE_ERROR);
|
|
59
|
+
return { kind: 'custom', root: custom };
|
|
60
|
+
}
|
|
61
|
+
// 대상이 폴더인지 확인한다. 없으면(ENOENT) 나중에 만들 수 있는 상태로 통과시킨다.
|
|
62
|
+
async function inspectDir(dir) {
|
|
63
|
+
try {
|
|
64
|
+
const stat = await fs.stat(dir);
|
|
65
|
+
if (!stat.isDirectory())
|
|
66
|
+
return OPERATOR_STORAGE_ERROR;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const code = error.code;
|
|
71
|
+
if (code === 'ENOENT')
|
|
72
|
+
return undefined;
|
|
73
|
+
return OPERATOR_STORAGE_ERROR;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// storage set에서 사용: 후보 경로가 절대 경로인지 확인하고, 없으면 폴더를 만든다.
|
|
77
|
+
// 접근 불가·폴더가 아닌 경우에는 오류를 던진다. 절대 경로를 오류 문구에 넣지 않는다.
|
|
78
|
+
export async function ensureOperatorStorageDir(candidate) {
|
|
79
|
+
const target = candidate.trim();
|
|
80
|
+
if (!path.isAbsolute(target)) {
|
|
81
|
+
throw new Error('Session 저장 위치는 절대 경로로 입력해야 합니다.');
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const stat = await fs.stat(target);
|
|
85
|
+
if (!stat.isDirectory()) {
|
|
86
|
+
throw new Error('Session 저장 위치를 사용할 수 없습니다. 폴더가 아닙니다.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof Error && error.message.includes('폴더가 아닙니다'))
|
|
91
|
+
throw error;
|
|
92
|
+
const code = error.code;
|
|
93
|
+
if (code !== 'ENOENT') {
|
|
94
|
+
throw new Error('Session 저장 위치를 사용할 수 없습니다. 접근할 수 없습니다.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await fs.mkdir(target, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new Error('Session 저장 위치를 사용할 수 없습니다. 폴더를 만들 수 없습니다.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function writeOperatorStoragePath(targetRoot, storagePath) {
|
|
105
|
+
const file = operatorStorageConfigFile(targetRoot);
|
|
106
|
+
const content = `${JSON.stringify({ sessionStoragePath: storagePath }, null, 2)}\n`;
|
|
107
|
+
await fs.writeFile(file, content, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
export async function hasOperatorStorageConfig(targetRoot) {
|
|
110
|
+
try {
|
|
111
|
+
await fs.access(operatorStorageConfigFile(targetRoot));
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 설정 파일만 제거한다. Session 기록 파일은 건드리지 않는다.
|
|
119
|
+
export async function removeOperatorStorageConfig(targetRoot) {
|
|
120
|
+
try {
|
|
121
|
+
await fs.unlink(operatorStorageConfigFile(targetRoot));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// 이미 없는 경우 무시
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
// 플래그(--agent 등) 없이 실행했을 때만 대화형으로 값을 묻습니다.
|
|
5
|
+
// 비대화형(파이프)에서는 null을 돌려주어 명령 쪽에서 오류를 내게 합니다.
|
|
6
|
+
export async function askText(message) {
|
|
7
|
+
if (!input.isTTY)
|
|
8
|
+
return null;
|
|
9
|
+
const rl = readline.createInterface({ input, output });
|
|
10
|
+
try {
|
|
11
|
+
const answer = await rl.question(`${message} `);
|
|
12
|
+
const trimmed = answer.trim();
|
|
13
|
+
return trimmed || null;
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
rl.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// 방향키(↑/↓) + Enter, 숫자 직접 입력을 지원하는 목록 선택.
|
|
20
|
+
// TTY가 아니면 null을 돌려줘서 호출부가 번호 선택 폴백으로 넘어가게 합니다.
|
|
21
|
+
export function arrowList(message, choices, defaultIndex = 0) {
|
|
22
|
+
if (!input.isTTY)
|
|
23
|
+
return Promise.resolve(null);
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let selected = Math.min(Math.max(defaultIndex, 0), Math.max(choices.length - 1, 0));
|
|
26
|
+
emitKeypressEvents(input);
|
|
27
|
+
let rawMode = false;
|
|
28
|
+
const render = () => {
|
|
29
|
+
output.write(`${message}\n`);
|
|
30
|
+
choices.forEach((choice, index) => {
|
|
31
|
+
output.write(`${index === selected ? '> ' : ' '}${index + 1}. ${choice}\n`);
|
|
32
|
+
});
|
|
33
|
+
output.write('↑/↓ 이동, Enter 확인, 번호 직접 입력, Ctrl+C 종료\n');
|
|
34
|
+
};
|
|
35
|
+
const cleanup = (value) => {
|
|
36
|
+
if (rawMode && input.setRawMode) {
|
|
37
|
+
try {
|
|
38
|
+
input.setRawMode(false);
|
|
39
|
+
}
|
|
40
|
+
catch { /* noop */ }
|
|
41
|
+
}
|
|
42
|
+
input.off('keypress', onKeypress);
|
|
43
|
+
process.stdin.pause();
|
|
44
|
+
resolve(value);
|
|
45
|
+
};
|
|
46
|
+
const onKeypress = (str, key) => {
|
|
47
|
+
if (key.name === 'up') {
|
|
48
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
49
|
+
render();
|
|
50
|
+
}
|
|
51
|
+
else if (key.name === 'down') {
|
|
52
|
+
selected = (selected + 1) % choices.length;
|
|
53
|
+
render();
|
|
54
|
+
}
|
|
55
|
+
else if (key.name === 'return')
|
|
56
|
+
cleanup(selected);
|
|
57
|
+
else if (key.name === 'escape')
|
|
58
|
+
cleanup(-1);
|
|
59
|
+
else if (key.ctrl && key.name === 'c')
|
|
60
|
+
cleanup(-1);
|
|
61
|
+
else if (str && /^\d$/.test(str)) {
|
|
62
|
+
const number = Number(str);
|
|
63
|
+
if (number >= 1 && number <= choices.length)
|
|
64
|
+
cleanup(number - 1);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
input.on('keypress', onKeypress);
|
|
68
|
+
if (input.setRawMode) {
|
|
69
|
+
try {
|
|
70
|
+
input.setRawMode(true);
|
|
71
|
+
rawMode = true;
|
|
72
|
+
}
|
|
73
|
+
catch { /* noop */ }
|
|
74
|
+
}
|
|
75
|
+
render();
|
|
76
|
+
});
|
|
77
|
+
}
|