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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { resolveScope } from './config/paths.js';
|
|
2
|
+
import { readVersionInfo } from './config/managed.js';
|
|
3
|
+
import { parseOptions, createIo, printHelp } from './output/format.js';
|
|
4
|
+
import { setupCommand, enableCommand, disableCommand, uninstallCommand } from './commands/lifecycle.js';
|
|
5
|
+
import { statusCommand, doctorCommand } from './commands/status.js';
|
|
6
|
+
import { dashboardCommand } from './commands/dashboard.js';
|
|
7
|
+
import { defaultCommand } from './commands/default.js';
|
|
8
|
+
import { onCommand, offCommand } from './commands/lifecycle.js';
|
|
9
|
+
import { providerCommand } from './commands/provider.js';
|
|
10
|
+
import { useCommand, connectCommand, disconnectCommand, switchCommand } from './commands/use.js';
|
|
11
|
+
import { sessionCommand } from './commands/session/index.js';
|
|
12
|
+
import { upgradeCommand } from './commands/upgrade.js';
|
|
13
|
+
import { migrateCommand } from './commands/migrate.js';
|
|
14
|
+
function safeError(message, verbose) {
|
|
15
|
+
if (verbose)
|
|
16
|
+
return message;
|
|
17
|
+
if (/ENOENT|EACCES|EPERM|spawn EINVAL/i.test(message))
|
|
18
|
+
return '필요한 파일이나 실행 권한을 확인하지 못했습니다. `jutell doctor`를 실행해 주세요.';
|
|
19
|
+
return message.replace(/[A-Za-z]:[\\/][^\r\n'" ]+/g, '[경로]');
|
|
20
|
+
}
|
|
21
|
+
export async function run(argv = process.argv.slice(2), io = createIo(), legacyAlias = false) {
|
|
22
|
+
if (legacyAlias)
|
|
23
|
+
io.write('`beginner-bridge`는 이전 명령입니다. 앞으로는 `jutell` 사용을 권장합니다.');
|
|
24
|
+
if (argv.includes('--version')) {
|
|
25
|
+
io.write((await readVersionInfo()).cli);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const { command, options, defaultInvocation, extraArgs } = parseOptions(argv);
|
|
30
|
+
if (command === 'help') {
|
|
31
|
+
printHelp(io);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const paths = resolveScope(options.scope);
|
|
35
|
+
if (options.statusOnly)
|
|
36
|
+
await statusCommand(paths, options, io);
|
|
37
|
+
else if (defaultInvocation)
|
|
38
|
+
await defaultCommand(paths, options, io);
|
|
39
|
+
else if (command === 'setup')
|
|
40
|
+
await setupCommand(paths, options, io);
|
|
41
|
+
else if (command === 'dashboard')
|
|
42
|
+
await dashboardCommand(paths, options, io);
|
|
43
|
+
else if (command === 'on')
|
|
44
|
+
await onCommand(paths, options, io);
|
|
45
|
+
else if (command === 'off')
|
|
46
|
+
await offCommand(paths, options, io);
|
|
47
|
+
else if (command === 'status')
|
|
48
|
+
await statusCommand(paths, options, io);
|
|
49
|
+
else if (command === 'enable')
|
|
50
|
+
await enableCommand(paths, options, io);
|
|
51
|
+
else if (command === 'disable')
|
|
52
|
+
await disableCommand(paths, options, io);
|
|
53
|
+
else if (command === 'doctor')
|
|
54
|
+
await doctorCommand(paths, options, io);
|
|
55
|
+
else if (command === 'uninstall')
|
|
56
|
+
await uninstallCommand(paths, options, io);
|
|
57
|
+
else if (command === 'provider')
|
|
58
|
+
await providerCommand(paths, options, io, extraArgs);
|
|
59
|
+
else if (command === 'use')
|
|
60
|
+
await useCommand(paths, options, io, [command, ...extraArgs]);
|
|
61
|
+
else if (command === 'connect')
|
|
62
|
+
await connectCommand(paths, options, io, [command, ...extraArgs]);
|
|
63
|
+
else if (command === 'disconnect')
|
|
64
|
+
await disconnectCommand(paths, options, io, [command, ...extraArgs]);
|
|
65
|
+
else if (command === 'switch')
|
|
66
|
+
await switchCommand(paths, options, io, [command, ...extraArgs]);
|
|
67
|
+
else if (command === 'upgrade')
|
|
68
|
+
await upgradeCommand(paths, options, io);
|
|
69
|
+
else if (command === 'migrate')
|
|
70
|
+
await migrateCommand(paths, options, io);
|
|
71
|
+
else if (command === 'session')
|
|
72
|
+
await sessionCommand(paths, options, io, extraArgs);
|
|
73
|
+
else
|
|
74
|
+
throw new Error(`알 수 없는 명령입니다: ${command}`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : '작업을 처리하지 못했습니다.';
|
|
79
|
+
io.error(`오류: ${safeError(message, argv.includes('--verbose'))}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { assets } from '../config/paths.js';
|
|
6
|
+
import { exists } from '../config/managed.js';
|
|
7
|
+
import { openBrowser } from '../process/system.js';
|
|
8
|
+
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml' };
|
|
9
|
+
async function readMarker(file) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function alive(pid) {
|
|
18
|
+
if (!pid)
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function serveStatic(req, res, root) {
|
|
29
|
+
const requestPath = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname);
|
|
30
|
+
const relative = requestPath === '/' ? 'index.html' : requestPath.replace(/^\/+/, '');
|
|
31
|
+
if (!relative || relative.includes('..')) {
|
|
32
|
+
res.statusCode = 400;
|
|
33
|
+
res.end('잘못된 요청입니다.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const file = path.join(root, relative);
|
|
37
|
+
if (!(await exists(file))) {
|
|
38
|
+
res.statusCode = 404;
|
|
39
|
+
res.end('파일을 찾을 수 없습니다.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
res.statusCode = 200;
|
|
43
|
+
res.setHeader('Content-Type', contentTypes[path.extname(file).toLowerCase()] ?? 'application/octet-stream');
|
|
44
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
45
|
+
res.end(await fs.readFile(file));
|
|
46
|
+
}
|
|
47
|
+
export async function dashboardCommand(paths, options, io) {
|
|
48
|
+
const marker = path.join(paths.dataRoot, 'dashboard.json');
|
|
49
|
+
const existing = await readMarker(marker);
|
|
50
|
+
if (existing && await alive(existing.pid)) {
|
|
51
|
+
const url = `http://127.0.0.1:${existing.port}`;
|
|
52
|
+
io.write(`로컬 관리자 화면이 이미 실행 중입니다.\n${url}`);
|
|
53
|
+
if (options.openBrowser)
|
|
54
|
+
await openBrowser(url, io);
|
|
55
|
+
return { started: false, url };
|
|
56
|
+
}
|
|
57
|
+
await fs.rm(marker, { force: true });
|
|
58
|
+
process.env.BEGINNER_BRIDGE_MCP_SERVER = path.join(assets().mcpServer, 'index.js');
|
|
59
|
+
process.env.JUTELL_TEMPLATES_ROOT = path.join(assets().root, 'templates', 'request-builder');
|
|
60
|
+
const module = await import(pathToFileURL(assets().localAdminServer).href);
|
|
61
|
+
const server = createServer((req, res) => {
|
|
62
|
+
if ((req.url ?? '/').startsWith('/api/'))
|
|
63
|
+
void module.handleApiRequest(req, res, paths.targetRoot);
|
|
64
|
+
else
|
|
65
|
+
void serveStatic(req, res, assets().localAdmin);
|
|
66
|
+
});
|
|
67
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); }); });
|
|
68
|
+
const address = server.address();
|
|
69
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
70
|
+
await fs.mkdir(paths.dataRoot, { recursive: true });
|
|
71
|
+
await fs.writeFile(marker, `${JSON.stringify({ pid: process.pid, port })}\n`, 'utf8');
|
|
72
|
+
const url = `http://127.0.0.1:${port}`;
|
|
73
|
+
io.write(`JuTell 로컬 관리자를 실행했습니다.\n${url}\n종료하려면 이 터미널에서 Ctrl+C를 누르세요.`);
|
|
74
|
+
if (options.openBrowser)
|
|
75
|
+
await openBrowser(url, io);
|
|
76
|
+
const close = async () => { await fs.rm(marker, { force: true }); server.close(); };
|
|
77
|
+
process.once('SIGINT', () => void close().then(() => process.exit(0)));
|
|
78
|
+
process.once('SIGTERM', () => void close().then(() => process.exit(0)));
|
|
79
|
+
await new Promise((resolve) => server.once('close', resolve));
|
|
80
|
+
return { started: true, url };
|
|
81
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { stdin } from 'node:process';
|
|
2
|
+
import { dashboardCommand } from './dashboard.js';
|
|
3
|
+
import { enableCommand, setupCommand } from './lifecycle.js';
|
|
4
|
+
import { getStatus } from './status.js';
|
|
5
|
+
import { useCommand } from './use.js';
|
|
6
|
+
import { AGENT_PROVIDERS } from '../installer/providers.js';
|
|
7
|
+
const profileLabels = {
|
|
8
|
+
minimal: '최소 보고',
|
|
9
|
+
balanced: '균형 보고',
|
|
10
|
+
learning: '학습 보고',
|
|
11
|
+
detailed: '상세 보고',
|
|
12
|
+
};
|
|
13
|
+
const profileChoices = [
|
|
14
|
+
{ value: 'balanced', label: '균형 보고', note: '처음 사용에 적당한 기본값 (권장)' },
|
|
15
|
+
{ value: 'minimal', label: '최소 보고', note: '핵심만 짧게' },
|
|
16
|
+
{ value: 'learning', label: '학습 보고', note: '개발 용어를 조금 더 설명' },
|
|
17
|
+
{ value: 'detailed', label: '상세 보고', note: '복잡한 작업을 자세히' },
|
|
18
|
+
];
|
|
19
|
+
function needsRepair(status) {
|
|
20
|
+
return !status.configValid || !status.skillInstalled || !status.agentsManaged || status.codexPreparation !== 'enabled';
|
|
21
|
+
}
|
|
22
|
+
function readyMessage(status) {
|
|
23
|
+
const ready = status.configValid && status.skillInstalled && status.agentsManaged && status.codexPreparation === 'enabled';
|
|
24
|
+
if (!ready) {
|
|
25
|
+
return `JuTell 연결이 일부 준비되지 않았습니다.
|
|
26
|
+
|
|
27
|
+
설정과 Skill을 확인했지만 AI Agent Provider 연결 준비가 완료되지 않았습니다.
|
|
28
|
+
관리자 화면에서 현재 상태를 확인할 수 있습니다.`;
|
|
29
|
+
}
|
|
30
|
+
return `JuTell 준비 완료
|
|
31
|
+
|
|
32
|
+
✓ 설정 연결됨
|
|
33
|
+
✓ Skill 연결됨
|
|
34
|
+
✓ AI Agent 연결 준비 완료
|
|
35
|
+
✓ 안전 보고 규칙 적용됨
|
|
36
|
+
|
|
37
|
+
현재 보고 방식: ${profileLabels[status.profile]}
|
|
38
|
+
활성 기능: ${status.activeFeatureCount}개
|
|
39
|
+
|
|
40
|
+
새 AI Agent 세션부터 사용할 수 있습니다.
|
|
41
|
+
실제 도구 호출 여부는 해당 Provider에서 확인할 수 있습니다.`;
|
|
42
|
+
}
|
|
43
|
+
const firstRunMessage = `이 프로젝트에는 아직 JuTell이 연결되지 않았습니다.
|
|
44
|
+
|
|
45
|
+
JuTell을 연결하면:
|
|
46
|
+
- AI 작업을 쉬운 말로 보고받을 수 있습니다.
|
|
47
|
+
- 현재 설정에 맞춰 보고 길이와 설명 방식을 조절할 수 있습니다.
|
|
48
|
+
- 연결된 AI Agent에서 JuTell MCP를 사용할 수 있습니다.`;
|
|
49
|
+
async function firstRunWizard(io) {
|
|
50
|
+
io.write(`Welcome to JuTell! 🎉
|
|
51
|
+
|
|
52
|
+
AI가 한 일을 쉽게 이해하고, 검증하고, 다음 작업으로 이어가도록 돕습니다.
|
|
53
|
+
몇 가지만 고르면 이 프로젝트에 연결을 준비합니다.
|
|
54
|
+
`);
|
|
55
|
+
const agentChoices = AGENT_PROVIDERS.map((provider) => ({
|
|
56
|
+
value: provider.id,
|
|
57
|
+
label: provider.label,
|
|
58
|
+
note: provider.status === 'planned' ? '준비 중' : provider.description,
|
|
59
|
+
}));
|
|
60
|
+
let agent = 'codex';
|
|
61
|
+
while (true) {
|
|
62
|
+
const picked = await io.choose('① 사용 중인 AI Agent를 선택하세요.', agentChoices, 'codex');
|
|
63
|
+
const provider = AGENT_PROVIDERS.find((item) => item.id === picked);
|
|
64
|
+
if (provider && provider.status !== 'planned') {
|
|
65
|
+
agent = provider.id;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
io.write(`${provider?.label ?? '선택한 Agent'}는 아직 준비 중입니다. 지금 사용할 수 있는 Agent를 선택하세요.`);
|
|
69
|
+
}
|
|
70
|
+
const profile = (await io.choose('② 보고 방식을 선택하세요. 나중에 언제든 바꿀 수 있습니다.', profileChoices, 'balanced'));
|
|
71
|
+
const agentLabel = AGENT_PROVIDERS.find((item) => item.id === agent)?.label ?? agent;
|
|
72
|
+
io.write(`\n③ 선택 완료: ${agentLabel} · ${profileLabels[profile]}\n연결을 준비합니다.\n`);
|
|
73
|
+
return { agent, profile };
|
|
74
|
+
}
|
|
75
|
+
export async function defaultCommand(paths, options, io) {
|
|
76
|
+
let status = await getStatus(paths);
|
|
77
|
+
if (!status.configExists) {
|
|
78
|
+
if (options.yes || stdin.isTTY !== true) {
|
|
79
|
+
io.write(firstRunMessage);
|
|
80
|
+
if (!options.yes && !(await io.ask('이 프로젝트에 연결할까요?', true))) {
|
|
81
|
+
io.write('JuTell을 연결하지 않았습니다.');
|
|
82
|
+
return { cancelled: true };
|
|
83
|
+
}
|
|
84
|
+
await setupCommand(paths, { ...options, yes: true, oneCommand: true, activateMcp: true }, io);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const picked = await firstRunWizard(io);
|
|
88
|
+
await useCommand(paths, { ...options, yes: true, profile: picked.profile }, io, ['use', picked.agent]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (needsRepair(status)) {
|
|
92
|
+
io.write('JuTell 연결이 일부 준비되지 않았습니다.\nSkill, AGENTS.md, AI Agent Provider 연결 준비를 안전하게 확인할 수 있습니다.');
|
|
93
|
+
if (!options.yes && !(await io.ask('다시 켜고 관리자 화면을 열까요?', true))) {
|
|
94
|
+
io.write('현재 설정을 그대로 두고 관리자 화면을 엽니다.');
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await enableCommand(paths, { ...options, yes: true, oneCommand: true }, io);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
status = await getStatus(paths);
|
|
101
|
+
io.write(readyMessage(status));
|
|
102
|
+
return dashboardCommand(paths, options, io);
|
|
103
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { assets, codexScopedPaths, packageRoot, safeLocation } from '../config/paths.js';
|
|
3
|
+
import { readBridgeConfig, snapshot, restore } from '../config/managed.js';
|
|
4
|
+
import { ensureBridgeConfig, setMcpDisabled, setMcpEnabled } from '../installer/config.js';
|
|
5
|
+
import { installSkill, recordSkillFiles, removeAddedSkillFiles, removeManagedSkillFiles } from '../installer/skill.js';
|
|
6
|
+
import { agentsFile, ensureJuTellAgentsBlock, removeJuTellAgentsBlock } from '../installer/agents.js';
|
|
7
|
+
import { registerMcp, removeMcp } from '../config/managed.js';
|
|
8
|
+
import { removeOpenCodeMcp, readOpenCodeRegistration, setOpenCodeEnabled } from '../installer/opencode.js';
|
|
9
|
+
import { removeClaudeMcp } from '../installer/claude.js';
|
|
10
|
+
import { scopeLabel } from '../output/format.js';
|
|
11
|
+
import { codexDetected, nodeMajorVersion, operatingSystem } from '../process/system.js';
|
|
12
|
+
function validProfile(value) {
|
|
13
|
+
return !value || ['minimal', 'balanced', 'learning', 'detailed'].includes(value);
|
|
14
|
+
}
|
|
15
|
+
export async function setupCommand(paths, options, io) {
|
|
16
|
+
if (!validProfile(options.profile))
|
|
17
|
+
throw new Error('Profile은 minimal, balanced, learning, detailed 중 하나여야 합니다.');
|
|
18
|
+
const currentConfig = await readBridgeConfig(paths);
|
|
19
|
+
const plannedProfile = options.profile ?? currentConfig.config.profile;
|
|
20
|
+
const migrationNote = currentConfig.source === 'legacy' ? '\n기존 설정을 읽었습니다. 승인하면 .jutell.json을 만들고 기존 파일은 보존합니다.\n' : '';
|
|
21
|
+
if (!options.oneCommand)
|
|
22
|
+
io.write(`JuTell 설치 미리보기\n${migrationNote}\n\n운영체제: ${operatingSystem()}\nNode: ${process.versions.node} (${nodeMajorVersion() >= 18 ? '지원 범위' : '낮은 버전'})\nAI Agent Provider 감지: ${codexDetected() ? '현재 지원 Provider 확인' : '직접 확인 필요'}\n설치 범위: ${scopeLabel(paths.scope)}\nProfile: ${plannedProfile}\nSkill: ${options.mcpOnly ? '변경하지 않음' : '설치 또는 기존 파일 유지'}\nMCP: ${options.skillOnly ? '변경하지 않음' : '기존 설정을 보존하고 관리 블록 등록'}\n기본 자동 시작: OFF\n`);
|
|
23
|
+
if (!options.yes && !(await io.ask('위 변경을 진행할까요?')))
|
|
24
|
+
return { cancelled: true };
|
|
25
|
+
const configSnapshot = await snapshot(paths.configFile);
|
|
26
|
+
const codexSnapshot = await snapshot(codexScopedPaths(paths).codexConfigFile);
|
|
27
|
+
const agentsSnapshot = paths.scope === 'project' ? await snapshot(agentsFile(paths.targetRoot)) : undefined;
|
|
28
|
+
let skillResult = { conflicts: [], changed: [] };
|
|
29
|
+
try {
|
|
30
|
+
skillResult = options.mcpOnly ? { conflicts: [], changed: [] } : await installSkill(assets().skill, paths.skillRoot);
|
|
31
|
+
const ensured = await ensureBridgeConfig(paths, options.profile);
|
|
32
|
+
if (paths.scope === 'project' && !options.mcpOnly)
|
|
33
|
+
await ensureJuTellAgentsBlock(paths.targetRoot);
|
|
34
|
+
if (!options.skillOnly) {
|
|
35
|
+
if (options.activateMcp)
|
|
36
|
+
await setMcpEnabled(paths, true);
|
|
37
|
+
// Codex only reads MCP servers from its global config, so registration
|
|
38
|
+
// always targets that file regardless of --project/--global (see
|
|
39
|
+
// codexScopedPaths). .jutell.json/Skill/AGENTS.md above stay scoped
|
|
40
|
+
// to what the user requested.
|
|
41
|
+
await registerMcp(codexScopedPaths(paths), packageRoot(), options.activateMcp || ensured.config.mcp?.enabled === true);
|
|
42
|
+
}
|
|
43
|
+
await recordSkillFiles(paths, skillResult.changed);
|
|
44
|
+
if (!options.oneCommand) {
|
|
45
|
+
io.write(`설치가 완료되었습니다.\n\n설치 범위: ${scopeLabel(paths.scope)}\nProfile: ${ensured.config.profile}\nSkill: ${skillResult.conflicts.length ? '충돌 파일을 보존함' : '설치됨'}\nMCP: ${options.skillOnly ? '변경하지 않음' : `등록됨 (${options.activateMcp ? '활성화' : '기본 활성화: 꺼짐'})`}\n설정: ${safeLocation(paths.scope, 'config')}${options.skillOnly ? '' : `\nCodex MCP: ${safeLocation(paths.scope, 'codex')} (Codex는 전역 설정만 읽습니다)`}\n\n다음 실행: jutell`);
|
|
46
|
+
if (skillResult.conflicts.length)
|
|
47
|
+
io.write(`\n주의: 기존 파일을 덮어쓰지 않았습니다: ${skillResult.conflicts.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
return { cancelled: false, skillResult };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
await restore(configSnapshot);
|
|
53
|
+
await restore(codexSnapshot);
|
|
54
|
+
if (agentsSnapshot)
|
|
55
|
+
await restore(agentsSnapshot);
|
|
56
|
+
await removeAddedSkillFiles(paths.skillRoot, skillResult.changed);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function enableCommand(paths, options, io) {
|
|
61
|
+
if (!options.yes && !(await io.ask(`JuTell을 ${scopeLabel(paths.scope)}에서 활성화할까요?`)))
|
|
62
|
+
return { cancelled: true };
|
|
63
|
+
const configSnapshot = await snapshot(paths.configFile);
|
|
64
|
+
const codexSnapshot = await snapshot(codexScopedPaths(paths).codexConfigFile);
|
|
65
|
+
const agentsSnapshot = paths.scope === 'project' ? await snapshot(agentsFile(paths.targetRoot)) : undefined;
|
|
66
|
+
let skillResult = { conflicts: [], changed: [] };
|
|
67
|
+
try {
|
|
68
|
+
skillResult = options.mcpOnly ? { conflicts: [], changed: [] } : await installSkill(assets().skill, paths.skillRoot);
|
|
69
|
+
const config = await ensureBridgeConfig(paths, undefined);
|
|
70
|
+
if (paths.scope === 'project' && !options.mcpOnly)
|
|
71
|
+
await ensureJuTellAgentsBlock(paths.targetRoot);
|
|
72
|
+
if (!options.skillOnly) {
|
|
73
|
+
const enabled = await setMcpEnabled(paths, true);
|
|
74
|
+
// Codex MCP registration always targets the global config (see codexScopedPaths).
|
|
75
|
+
await registerMcp(codexScopedPaths(paths), packageRoot(), enabled.mcp?.enabled === true);
|
|
76
|
+
const opencode = await readOpenCodeRegistration(paths, packageRoot(), enabled.mcp?.enabled === true);
|
|
77
|
+
if (opencode.registered)
|
|
78
|
+
await setOpenCodeEnabled(paths, packageRoot(), true);
|
|
79
|
+
}
|
|
80
|
+
await recordSkillFiles(paths, skillResult.changed);
|
|
81
|
+
if (!options.oneCommand) {
|
|
82
|
+
io.write(`활성화했습니다. Skill: ${options.mcpOnly ? '변경하지 않음' : '사용 가능'}, MCP: ${options.skillOnly ? '변경하지 않음' : '활성화됨'}.`);
|
|
83
|
+
if (!options.skillOnly)
|
|
84
|
+
io.write('새 AI Agent 세션 또는 재시작이 필요할 수 있습니다.');
|
|
85
|
+
if (skillResult.conflicts.length)
|
|
86
|
+
io.write(`주의: 기존 Skill 파일은 덮어쓰지 않았습니다: ${skillResult.conflicts.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
return { cancelled: false, config };
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await restore(configSnapshot);
|
|
92
|
+
await restore(codexSnapshot);
|
|
93
|
+
if (agentsSnapshot)
|
|
94
|
+
await restore(agentsSnapshot);
|
|
95
|
+
await removeAddedSkillFiles(paths.skillRoot, skillResult.changed);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function disableCommand(paths, options, io) {
|
|
100
|
+
const disableSkill = options.disableSkill;
|
|
101
|
+
const disableMcp = options.disableMcp || (!disableSkill && !options.disableAll);
|
|
102
|
+
if (!options.yes && !(await io.ask(`JuTell 연결을 ${scopeLabel(paths.scope)}에서 비활성화할까요?`)))
|
|
103
|
+
return { cancelled: true };
|
|
104
|
+
let codexSkipped = false;
|
|
105
|
+
if (disableMcp) {
|
|
106
|
+
const config = await setMcpDisabled(paths);
|
|
107
|
+
// Codex MCP registration is a single global entry shared by every
|
|
108
|
+
// project on this machine (see codexScopedPaths). A project-scoped
|
|
109
|
+
// disable only turns off *this project's* connection policy; it does
|
|
110
|
+
// not reach into that shared global entry unless the user explicitly
|
|
111
|
+
// asked for --global, so other projects using Codex + JuTell keep working.
|
|
112
|
+
if (paths.scope === 'global')
|
|
113
|
+
await registerMcp(codexScopedPaths(paths), packageRoot(), config.mcp?.enabled === true);
|
|
114
|
+
else
|
|
115
|
+
codexSkipped = true;
|
|
116
|
+
await setOpenCodeEnabled(paths, packageRoot(), false);
|
|
117
|
+
// Claude's own local/project scope is already correctly per-project
|
|
118
|
+
// (unlike Codex's forced-global registration), so removing it here has
|
|
119
|
+
// no cross-project blast radius - safe to always do, no scope gate needed.
|
|
120
|
+
await removeClaudeMcp(paths, packageRoot());
|
|
121
|
+
}
|
|
122
|
+
if (disableSkill)
|
|
123
|
+
await removeManagedSkillFiles(assets().skill, paths.skillRoot, paths);
|
|
124
|
+
if (disableSkill && paths.scope === 'project')
|
|
125
|
+
await removeJuTellAgentsBlock(paths.targetRoot);
|
|
126
|
+
io.write(`비활성화했습니다. Skill: ${disableSkill ? '비활성화됨' : '유지됨'}, MCP: ${disableMcp ? '비활성화됨' : '유지됨'}.`);
|
|
127
|
+
io.write('설정과 Beta Journal 데이터는 보존했습니다.');
|
|
128
|
+
if (codexSkipped)
|
|
129
|
+
io.write('Codex MCP 연결(전역 설정)은 다른 프로젝트와 공유되어 그대로 두었습니다. 모든 프로젝트에서 끄려면 jutell disable --global 을 실행하세요.');
|
|
130
|
+
return { cancelled: false };
|
|
131
|
+
}
|
|
132
|
+
export async function uninstallCommand(paths, options, io) {
|
|
133
|
+
const removeData = options.removeData;
|
|
134
|
+
const dataMessage = removeData ? '설정과 Beta Journal도 삭제합니다.' : '설정과 Beta Journal은 보존합니다.';
|
|
135
|
+
if (!options.yes && !(await io.ask(`JuTell을 제거할까요? ${dataMessage}`)))
|
|
136
|
+
return { cancelled: true };
|
|
137
|
+
// Codex MCP registration is a single global entry shared by every project
|
|
138
|
+
// on this machine (see codexScopedPaths). Removing JuTell from one project
|
|
139
|
+
// must not silently break that shared connection for other projects, so a
|
|
140
|
+
// project-scoped uninstall leaves it in place unless the user asked for
|
|
141
|
+
// --global.
|
|
142
|
+
const codexSkipped = paths.scope !== 'global';
|
|
143
|
+
if (!codexSkipped)
|
|
144
|
+
await removeMcp(codexScopedPaths(paths), packageRoot());
|
|
145
|
+
await removeManagedSkillFiles(assets().skill, paths.skillRoot, paths);
|
|
146
|
+
if (paths.scope === 'project')
|
|
147
|
+
await removeJuTellAgentsBlock(paths.targetRoot);
|
|
148
|
+
await removeOpenCodeMcp(paths, packageRoot());
|
|
149
|
+
// Same reasoning as disable: Claude's scope is already per-project, so no
|
|
150
|
+
// shared-global-entry risk to guard against here.
|
|
151
|
+
await removeClaudeMcp(paths, packageRoot());
|
|
152
|
+
if (removeData) {
|
|
153
|
+
await fs.rm(paths.configFile, { force: true });
|
|
154
|
+
await fs.rm(paths.dataRoot, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
io.write(`제거했습니다. ${removeData ? '설정과 Beta Journal도 삭제했습니다.' : '설정과 Beta Journal은 보존했습니다.'}`);
|
|
157
|
+
if (codexSkipped)
|
|
158
|
+
io.write('Codex MCP 연결(전역 설정)은 다른 프로젝트와 공유되어 제거하지 않았습니다. 모든 프로젝트에서 제거하려면 jutell uninstall --global 을 실행하세요.');
|
|
159
|
+
return { cancelled: false };
|
|
160
|
+
}
|
|
161
|
+
export async function onCommand(paths, options, io) {
|
|
162
|
+
return enableCommand(paths, { ...options, skillOnly: false, mcpOnly: false }, io);
|
|
163
|
+
}
|
|
164
|
+
export async function offCommand(paths, options, io) {
|
|
165
|
+
return disableCommand(paths, { ...options, disableSkill: true, disableMcp: true, disableAll: true }, io);
|
|
166
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { codexScopedPaths, packageRoot } from '../config/paths.js';
|
|
3
|
+
import { readBridgeConfig, readCodexRegistration, readText, writeTextSafely } from '../config/managed.js';
|
|
4
|
+
import { ensureBridgeConfig } from '../installer/config.js';
|
|
5
|
+
import { readOpenCodeRegistration, registerOpenCodeMcp } from '../installer/opencode.js';
|
|
6
|
+
import { readClaudeRegistration } from '../installer/claude.js';
|
|
7
|
+
import { registerMcp } from '../config/managed.js';
|
|
8
|
+
export async function migrateCommand(paths, options, io) {
|
|
9
|
+
const clean = Boolean(options.clean || options.cleanLegacy || options.removeData);
|
|
10
|
+
const beforeConfig = await readBridgeConfig(paths);
|
|
11
|
+
const codexReg = await readCodexRegistration(codexScopedPaths(paths), packageRoot(), true);
|
|
12
|
+
const opencodeReg = await readOpenCodeRegistration(paths, packageRoot(), true);
|
|
13
|
+
const claudeReg = await readClaudeRegistration(paths, packageRoot(), true);
|
|
14
|
+
const legacyFileExists = (await readText(paths.legacyConfigFile)) !== undefined;
|
|
15
|
+
const hasLegacyConfig = beforeConfig.source === 'legacy' || legacyFileExists;
|
|
16
|
+
const hasLegacyCodex = codexReg.legacyRegistered && !codexReg.canonicalRegistered;
|
|
17
|
+
const hasLegacyOpencode = opencodeReg.legacyRegistered && !opencodeReg.canonicalRegistered;
|
|
18
|
+
const hasBothCodex = codexReg.bothRegistered;
|
|
19
|
+
const hasBothOpencode = opencodeReg.bothRegistered;
|
|
20
|
+
const hasAnyLegacy = hasLegacyConfig || hasLegacyCodex || hasLegacyOpencode || hasBothCodex || hasBothOpencode;
|
|
21
|
+
if (!hasAnyLegacy && !clean) {
|
|
22
|
+
io.write('이전 beginner_bridge 상태를 찾지 못했습니다.\n이미 canonical jutell 상태입니다.\n정리하려면 jutell migrate --clean 을 실행하세요 (canonical이 확인된 뒤에만 레거시를 제거합니다).');
|
|
23
|
+
return { cancelled: false };
|
|
24
|
+
}
|
|
25
|
+
if (!clean) {
|
|
26
|
+
// Safe migration: READ LEGACY, WRITE CANONICAL, keep legacy
|
|
27
|
+
const lines = ['JuTell 마이그레이션을 준비합니다. (READ LEGACY → WRITE CANONICAL, keep legacy)'];
|
|
28
|
+
if (hasLegacyConfig) {
|
|
29
|
+
await ensureBridgeConfig(paths, undefined);
|
|
30
|
+
const after = await readBridgeConfig(paths);
|
|
31
|
+
lines.push(`- 설정: .beginner-bridge.json → .jutell.json 생성 (profile: ${after.config.profile}). 이전 파일은 보존했습니다.`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
lines.push('- 설정: .jutell.json 이미 존재 — 유지했습니다.');
|
|
35
|
+
}
|
|
36
|
+
// Codex legacy → canonical
|
|
37
|
+
if (hasLegacyCodex) {
|
|
38
|
+
await registerMcp(codexScopedPaths(paths), packageRoot(), true);
|
|
39
|
+
lines.push('- Codex: 이전 beginner_bridge 항목을 보존하고 새 jutell 항목을 추가했습니다. (이전 항목 자동 삭제 안 함)');
|
|
40
|
+
}
|
|
41
|
+
else if (hasBothCodex) {
|
|
42
|
+
lines.push('- Codex: canonical jutell과 legacy beginner_bridge가 모두 있어 그대로 두었습니다.');
|
|
43
|
+
}
|
|
44
|
+
else if (codexReg.canonicalRegistered) {
|
|
45
|
+
lines.push('- Codex: 이미 canonical jutell — 유지했습니다.');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
lines.push('- Codex: legacy 없음 — 건너뛰었습니다.');
|
|
49
|
+
}
|
|
50
|
+
// OpenCode legacy → canonical
|
|
51
|
+
if (hasLegacyOpencode) {
|
|
52
|
+
await registerOpenCodeMcp(paths, packageRoot(), true);
|
|
53
|
+
lines.push('- OpenCode: 이전 beginner_bridge를 보존하고 새 jutell을 추가했습니다.');
|
|
54
|
+
}
|
|
55
|
+
else if (hasBothOpencode) {
|
|
56
|
+
lines.push('- OpenCode: canonical+legacy 모두 있어 그대로 두었습니다.');
|
|
57
|
+
}
|
|
58
|
+
else if (opencodeReg.canonicalRegistered) {
|
|
59
|
+
lines.push('- OpenCode: 이미 canonical jutell — 유지했습니다.');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
lines.push('- OpenCode: legacy 없음 — 건너뛰었습니다.');
|
|
63
|
+
}
|
|
64
|
+
if (claudeReg.registered)
|
|
65
|
+
lines.push('- Claude Code: 이미 canonical jutell — 유지했습니다.');
|
|
66
|
+
else
|
|
67
|
+
lines.push('- Claude Code: legacy 없음 (Claude는 신규 adapter, legacy 없음) — 건너뛰었습니다.');
|
|
68
|
+
lines.push('레거시는 그대로 보존했습니다.');
|
|
69
|
+
lines.push('다음: jutell status / jutell doctor 로 확인한 뒤, canonical이 활성화된 것을 확인하면 jutell migrate --clean 으로 레거시를 정리할 수 있습니다.');
|
|
70
|
+
io.write(lines.join('\n'));
|
|
71
|
+
return { cancelled: false };
|
|
72
|
+
}
|
|
73
|
+
// --clean : remove legacy only after safe verification
|
|
74
|
+
const errors = [];
|
|
75
|
+
if (hasLegacyConfig) {
|
|
76
|
+
// Verify canonical exists and is valid before deleting legacy
|
|
77
|
+
const canonicalExists = await readText(paths.configFile);
|
|
78
|
+
if (!canonicalExists)
|
|
79
|
+
errors.push('.jutell.json이 없어 레거시 .beginner-bridge.json을 삭제하지 않았습니다. 먼저 jutell migrate 를 실행하세요.');
|
|
80
|
+
else {
|
|
81
|
+
try {
|
|
82
|
+
await fs.rm(paths.legacyConfigFile, { force: true });
|
|
83
|
+
// also legacy local dir if empty? keep for safety, only remove file per spec (not auto delete all data)
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
errors.push(`레거시 설정 삭제 실패: ${e instanceof Error ? e.message : String(e)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Codex legacy clean: only if canonical exists
|
|
91
|
+
if (hasBothCodex || hasLegacyCodex) {
|
|
92
|
+
const afterCodex = await readCodexRegistration(codexScopedPaths(paths), packageRoot(), true);
|
|
93
|
+
if (!afterCodex.canonicalRegistered) {
|
|
94
|
+
errors.push('Codex에 canonical jutell이 없어 legacy beginner_bridge를 삭제하지 않았습니다.');
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Remove only legacy block: read file and strip legacy marker blocks
|
|
98
|
+
const file = codexScopedPaths(paths).codexConfigFile;
|
|
99
|
+
const text = (await readText(file)) ?? '';
|
|
100
|
+
// Use managed.ts patterns: remove only legacy/beginner_bridge blocks, keep canonical and other
|
|
101
|
+
// Direct patterns for legacy markers (keep canonical JUTELL_CLI_MCP_BEGIN/END)
|
|
102
|
+
const legacyPattern = /# BEGINNER_BRIDGE_CLI_MCP_BEGIN[\s\S]*?# BEGINNER_BRIDGE_CLI_MCP_END\n?/m;
|
|
103
|
+
const legacyPattern2 = /# BEGINNER_BRIDGE_MCP_BEGIN[\s\S]*?# BEGINNER_BRIDGE_MCP_END\n?/m;
|
|
104
|
+
let next = text.replace(legacyPattern, '').replace(legacyPattern2, '');
|
|
105
|
+
// Also remove unmarked legacy with heuristic: if beginner_bridge still present but not in managed block, check evidence
|
|
106
|
+
if (/^\s*\[mcp_servers\.beginner_bridge\]/m.test(next) && /(?:assets|apps)[\\/]mcp-server/i.test(next.slice(next.search(/^\s*\[mcp_servers\.beginner_bridge\]/m), next.search(/^\s*\[mcp_servers\.beginner_bridge\]/m) + 1200))) {
|
|
107
|
+
// Remove that section (from header until next header/marker or end)
|
|
108
|
+
const idx = next.search(/^\s*\[mcp_servers\.beginner_bridge\]/m);
|
|
109
|
+
const after = next.slice(idx);
|
|
110
|
+
const nextHeader = after.slice(1).search(/^\s*\[mcp_servers\./m);
|
|
111
|
+
const nextMarker = after.search(/#\s*JUTELL_CLI_MCP_BEGIN/m);
|
|
112
|
+
let cut = after.length;
|
|
113
|
+
if (nextHeader >= 0)
|
|
114
|
+
cut = Math.min(cut, nextHeader + 1);
|
|
115
|
+
if (nextMarker >= 0)
|
|
116
|
+
cut = Math.min(cut, nextMarker);
|
|
117
|
+
next = next.slice(0, idx) + after.slice(cut);
|
|
118
|
+
}
|
|
119
|
+
next = next.replace(/\n{3,}/g, '\n\n').trim();
|
|
120
|
+
await writeTextSafely(file, next ? `${next}\n` : '');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// OpenCode legacy clean
|
|
124
|
+
if (hasBothOpencode || hasLegacyOpencode) {
|
|
125
|
+
const afterOpencode = await readOpenCodeRegistration(paths, packageRoot(), true);
|
|
126
|
+
if (!afterOpencode.canonicalRegistered) {
|
|
127
|
+
errors.push('OpenCode에 canonical jutell이 없어 legacy를 삭제하지 않았습니다.');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// For OpenCode, bothRegistered case: remove legacy key from mcp object
|
|
131
|
+
const { file, text } = await (async () => {
|
|
132
|
+
const { promises: fs2 } = await import('node:fs');
|
|
133
|
+
const p = paths.opencodeConfigFile;
|
|
134
|
+
// opencode may be .jsonc, resolve via readOpenCodeRegistration file
|
|
135
|
+
const reg = await readOpenCodeRegistration(paths, packageRoot(), true);
|
|
136
|
+
const t = (await readText(reg.file)) ?? '';
|
|
137
|
+
return { file: reg.file, text: t };
|
|
138
|
+
})();
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(text.replace(/\/\/.*$/gm, '').replace(/,\s*([}\]])/g, '$1'));
|
|
141
|
+
if (parsed.mcp && parsed.mcp.beginner_bridge) {
|
|
142
|
+
delete parsed.mcp.beginner_bridge;
|
|
143
|
+
// Keep jutell, remove managed marker will be regenerated on next write? For clean, just remove legacy key and write plain json
|
|
144
|
+
await writeTextSafely(file, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
errors.push('OpenCode 설정 파싱 실패로 legacy 정리를 건너뛰었습니다.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (errors.length) {
|
|
153
|
+
io.write(['정리 중 일부를 건너뛰었습니다:', ...errors.map(e => `- ${e}`), '남은 레거시는 jutell doctor 로 확인하세요.'].join('\n'));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
io.write('레거시 정리가 끝났습니다.\n- .beginner-bridge.json (있었다면) 제거\n- Codex/OpenCode의 beginner_bridge 항목 제거\n관련 없는 설정은 보존했습니다.\njutell status / jutell doctor 로 확인하세요.');
|
|
157
|
+
}
|
|
158
|
+
return { cancelled: false };
|
|
159
|
+
}
|