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,180 @@
|
|
|
1
|
+
import { assets, codexScopedPaths, packageRoot } from '../config/paths.js';
|
|
2
|
+
import { readCodexRegistration, registerMcp, snapshot, restore } from '../config/managed.js';
|
|
3
|
+
import { ensureBridgeConfig, setMcpEnabled } from '../installer/config.js';
|
|
4
|
+
import { installSkill, recordSkillFiles, removeAddedSkillFiles } from '../installer/skill.js';
|
|
5
|
+
import { agentsFile, ensureJuTellAgentsBlock } from '../installer/agents.js';
|
|
6
|
+
import { opencodeDetected, readOpenCodeRegistration, registerOpenCodeMcp, setOpenCodeEnabled } from '../installer/opencode.js';
|
|
7
|
+
import { readClaudeRegistration, registerClaudeMcp, removeClaudeMcp } from '../installer/claude.js';
|
|
8
|
+
import { findProvider, supportedProviderNames } from '../installer/providers.js';
|
|
9
|
+
import { claudeDetected, codexDetected } from '../process/system.js';
|
|
10
|
+
function adapterFor(id) {
|
|
11
|
+
if (id === 'codex') {
|
|
12
|
+
// Codex only reads MCP servers from its global config (see
|
|
13
|
+
// codexScopedPaths), regardless of --project/--global.
|
|
14
|
+
return {
|
|
15
|
+
detected: codexDetected,
|
|
16
|
+
read: (paths, enabled) => readCodexRegistration(codexScopedPaths(paths), packageRoot(), enabled),
|
|
17
|
+
register: (paths, enabled) => registerMcp(codexScopedPaths(paths), packageRoot(), enabled),
|
|
18
|
+
deactivate: (paths) => registerMcp(codexScopedPaths(paths), packageRoot(), false),
|
|
19
|
+
notConnectedMessage: '연결된 Codex JuTell MCP가 없습니다. 먼저 jutell use codex 를 실행하세요.',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (id === 'claude-code') {
|
|
23
|
+
// Claude's own scope (local/user) already follows paths.scope directly -
|
|
24
|
+
// no forced-scope helper needed, unlike Codex.
|
|
25
|
+
return {
|
|
26
|
+
detected: claudeDetected,
|
|
27
|
+
read: (paths, enabled) => readClaudeRegistration(paths, packageRoot(), enabled),
|
|
28
|
+
register: (paths, enabled) => registerClaudeMcp(paths, packageRoot(), enabled),
|
|
29
|
+
deactivate: (paths) => removeClaudeMcp(paths, packageRoot()),
|
|
30
|
+
notConnectedMessage: '연결된 Claude Code JuTell MCP가 없습니다. 먼저 jutell use claude 를 실행하세요.',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
detected: opencodeDetected,
|
|
35
|
+
read: (paths, enabled) => readOpenCodeRegistration(paths, packageRoot(), enabled),
|
|
36
|
+
register: (paths, enabled) => registerOpenCodeMcp(paths, packageRoot(), enabled),
|
|
37
|
+
deactivate: (paths) => setOpenCodeEnabled(paths, packageRoot(), false),
|
|
38
|
+
notConnectedMessage: '연결된 OpenCode JuTell MCP가 없습니다. 먼저 jutell use opencode 를 실행하세요.',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function resolveTarget(args, io) {
|
|
42
|
+
const target = args[1];
|
|
43
|
+
if (!target)
|
|
44
|
+
throw new Error('Agent 이름이 필요합니다. 예: jutell use opencode');
|
|
45
|
+
const provider = findProvider(target);
|
|
46
|
+
if (!provider)
|
|
47
|
+
throw new Error(`알 수 없는 Agent입니다: ${target}\n현재 사용할 수 있는 Agent는 ${supportedProviderNames()}입니다.`);
|
|
48
|
+
if (provider.status === 'planned') {
|
|
49
|
+
io.write(`${provider.label} 연결은 아직 준비 중입니다.\n현재 사용할 수 있는 Agent는 ${supportedProviderNames()}입니다.`);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return provider;
|
|
53
|
+
}
|
|
54
|
+
async function registrationSnapshots(paths) {
|
|
55
|
+
const opencode = await readOpenCodeRegistration(paths, packageRoot(), false);
|
|
56
|
+
const files = [paths.configFile, paths.codexConfigFile, codexScopedPaths(paths).codexConfigFile, opencode.file, paths.claudeConfigFile];
|
|
57
|
+
if (paths.scope === 'project')
|
|
58
|
+
files.push(agentsFile(paths.targetRoot));
|
|
59
|
+
return Promise.all(files.map((file) => snapshot(file)));
|
|
60
|
+
}
|
|
61
|
+
async function registerProviderEnabled(paths, provider, io) {
|
|
62
|
+
const adapter = adapterFor(provider.id);
|
|
63
|
+
await adapter.register(paths, true);
|
|
64
|
+
const current = await adapter.read(paths, true);
|
|
65
|
+
if (current.canonicalRegistered && current.legacyRegistered) {
|
|
66
|
+
io.write('\n이전 beginner_bridge 항목을 그대로 두고 새 jutell 항목을 추가했습니다.\n이전 항목은 자동으로 삭제하지 않습니다. 제거는 추후 안전한 마이그레이션에서 안내합니다.');
|
|
67
|
+
}
|
|
68
|
+
if (provider.id === 'codex') {
|
|
69
|
+
io.write('\nCodex는 MCP 서버 목록을 사용자 전역 설정에서만 읽습니다.\nJuTell 프로젝트 규칙(AGENTS.md, Skill, 설정)은 이 프로젝트에 그대로 두고,\nCodex MCP 연결만 사용자 전역 설정(Codex 홈)에 등록했습니다.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function verifyRegistration(paths, provider) {
|
|
73
|
+
const current = await adapterFor(provider.id).read(paths, true);
|
|
74
|
+
if (!current.canonicalRegistered || !current.enabled)
|
|
75
|
+
throw new Error(`${provider.label} 연결 설정을 검증하지 못했습니다. jutell doctor를 실행해 주세요.`);
|
|
76
|
+
}
|
|
77
|
+
function printSuccess(io, provider, extra) {
|
|
78
|
+
io.write(`${provider.label} 연결이 끝났습니다.\n\n이제 ${provider.label}에서 새 대화를 열면\nJuTell이 자동으로 적용됩니다.\n실제 적용 여부는 새 대화에서 확인할 수 있습니다.\n\n연결 확인\n- AI 연결 설정\n- JuTell 규칙 연결\n- 기존 ${provider.label} 설정 보존`);
|
|
79
|
+
if (extra.othersDisabled)
|
|
80
|
+
io.write('\n\n다른 Agent의 JuTell 연결은 비활성화했습니다.');
|
|
81
|
+
if (extra.keepNote)
|
|
82
|
+
io.write('\n기존 다른 Agent 연결은 유지했습니다.');
|
|
83
|
+
if (!extra.detected)
|
|
84
|
+
io.write(`\n참고: ${provider.label} 명령을 찾지 못했습니다. 설치 후 다시 실행하세요.`);
|
|
85
|
+
}
|
|
86
|
+
async function rollback(snapshots, changed, paths) {
|
|
87
|
+
for (const item of snapshots)
|
|
88
|
+
await restore(item);
|
|
89
|
+
await removeAddedSkillFiles(paths.skillRoot, changed);
|
|
90
|
+
}
|
|
91
|
+
export async function useCommand(paths, options, io, args) {
|
|
92
|
+
const provider = await resolveTarget(args, io);
|
|
93
|
+
if (!provider)
|
|
94
|
+
return { cancelled: true };
|
|
95
|
+
const detected = adapterFor(provider.id).detected();
|
|
96
|
+
const snapshots = await registrationSnapshots(paths);
|
|
97
|
+
let changed = [];
|
|
98
|
+
try {
|
|
99
|
+
await ensureBridgeConfig(paths, options.profile);
|
|
100
|
+
const skillResult = await installSkill(assets().skill, paths.skillRoot);
|
|
101
|
+
changed = skillResult.changed;
|
|
102
|
+
if (paths.scope === 'project')
|
|
103
|
+
await ensureJuTellAgentsBlock(paths.targetRoot);
|
|
104
|
+
await setMcpEnabled(paths, true);
|
|
105
|
+
await registerProviderEnabled(paths, provider, io);
|
|
106
|
+
await recordSkillFiles(paths, changed);
|
|
107
|
+
await verifyRegistration(paths, provider);
|
|
108
|
+
printSuccess(io, provider, { detected, skill: true, agents: paths.scope === 'project', keepNote: true });
|
|
109
|
+
return { cancelled: false };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
await rollback(snapshots, changed, paths);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function connectCommand(paths, options, io, args) {
|
|
117
|
+
const provider = await resolveTarget(args, io);
|
|
118
|
+
if (!provider)
|
|
119
|
+
return { cancelled: true };
|
|
120
|
+
const detected = adapterFor(provider.id).detected();
|
|
121
|
+
const snapshots = await registrationSnapshots(paths);
|
|
122
|
+
try {
|
|
123
|
+
await ensureBridgeConfig(paths, undefined);
|
|
124
|
+
await setMcpEnabled(paths, true);
|
|
125
|
+
await registerProviderEnabled(paths, provider, io);
|
|
126
|
+
await verifyRegistration(paths, provider);
|
|
127
|
+
printSuccess(io, provider, { detected, keepNote: true });
|
|
128
|
+
return { cancelled: false };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
await rollback(snapshots, [], paths);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function disconnectCommand(paths, options, io, args) {
|
|
136
|
+
const provider = await resolveTarget(args, io);
|
|
137
|
+
if (!provider)
|
|
138
|
+
return { cancelled: true };
|
|
139
|
+
const adapter = adapterFor(provider.id);
|
|
140
|
+
const current = await adapter.read(paths, false);
|
|
141
|
+
if (current.conflict)
|
|
142
|
+
throw new Error(`${provider.label} 설정에 같은 이름의 관리되지 않는 MCP 항목이 있어 자동 변경하지 않았습니다.`);
|
|
143
|
+
if (!current.registered) {
|
|
144
|
+
io.write(adapter.notConnectedMessage);
|
|
145
|
+
return { cancelled: false };
|
|
146
|
+
}
|
|
147
|
+
await adapter.deactivate(paths);
|
|
148
|
+
io.write(`${provider.label} 연결을 끊었습니다.\nJuTell MCP는 비활성화했고 설정 항목은 유지됩니다. 새 ${provider.label} 세션부터 사용되지 않습니다.`);
|
|
149
|
+
return { cancelled: false };
|
|
150
|
+
}
|
|
151
|
+
export async function switchCommand(paths, options, io, args) {
|
|
152
|
+
const provider = await resolveTarget(args, io);
|
|
153
|
+
if (!provider)
|
|
154
|
+
return { cancelled: true };
|
|
155
|
+
const detected = adapterFor(provider.id).detected();
|
|
156
|
+
const snapshots = await registrationSnapshots(paths);
|
|
157
|
+
try {
|
|
158
|
+
for (const other of ['codex', 'opencode', 'claude-code']) {
|
|
159
|
+
if (other === provider.id)
|
|
160
|
+
continue;
|
|
161
|
+
const otherAdapter = adapterFor(other);
|
|
162
|
+
const current = await otherAdapter.read(paths, false);
|
|
163
|
+
const otherLabel = findProvider(other)?.label ?? other;
|
|
164
|
+
if (current.conflict)
|
|
165
|
+
throw new Error(`${otherLabel} 설정에 같은 이름의 관리되지 않는 MCP 항목이 있어 자동 변경하지 않았습니다.`);
|
|
166
|
+
if (current.registered && current.enabled)
|
|
167
|
+
await otherAdapter.deactivate(paths);
|
|
168
|
+
}
|
|
169
|
+
await ensureBridgeConfig(paths, undefined);
|
|
170
|
+
await setMcpEnabled(paths, true);
|
|
171
|
+
await registerProviderEnabled(paths, provider, io);
|
|
172
|
+
await verifyRegistration(paths, provider);
|
|
173
|
+
printSuccess(io, provider, { detected, othersDisabled: true });
|
|
174
|
+
return { cancelled: false };
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
await rollback(snapshots, [], paths);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
package/dist/compat.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assets } from './paths.js';
|
|
4
|
+
export const BEGIN_MARKER = '# JUTELL_CLI_MCP_BEGIN';
|
|
5
|
+
export const END_MARKER = '# JUTELL_CLI_MCP_END';
|
|
6
|
+
const PREVIOUS_BEGIN_MARKER = '# BEGINNER_BRIDGE_CLI_MCP_BEGIN';
|
|
7
|
+
const PREVIOUS_END_MARKER = '# BEGINNER_BRIDGE_CLI_MCP_END';
|
|
8
|
+
const LEGACY_BEGIN_MARKER = '# BEGINNER_BRIDGE_MCP_BEGIN';
|
|
9
|
+
const LEGACY_END_MARKER = '# BEGINNER_BRIDGE_MCP_END';
|
|
10
|
+
const CANONICAL_MCP_KEY = 'jutell';
|
|
11
|
+
const LEGACY_MCP_KEY = 'beginner_bridge';
|
|
12
|
+
export const FEATURE_IDS = ['changeSummary', 'userVisibleChanges', 'internalChanges', 'mainFiles', 'explainedDiff', 'glossary', 'validationResults', 'riskAssessment', 'userActions', 'nextActionSuggestions', 'requestClarificationGuide', 'manualEditGuidance', 'requestBuilder'];
|
|
13
|
+
export const PROFILES = ['minimal', 'balanced', 'learning', 'detailed'];
|
|
14
|
+
export const PROFILE_FEATURES = {
|
|
15
|
+
minimal: { changeSummary: true, userVisibleChanges: true, internalChanges: false, mainFiles: false, explainedDiff: false, glossary: false, validationResults: true, riskAssessment: false, userActions: true, nextActionSuggestions: false, requestClarificationGuide: false, manualEditGuidance: false, requestBuilder: true },
|
|
16
|
+
balanced: { changeSummary: true, userVisibleChanges: true, internalChanges: true, mainFiles: true, explainedDiff: true, glossary: true, validationResults: true, riskAssessment: true, userActions: true, nextActionSuggestions: true, requestClarificationGuide: true, manualEditGuidance: true, requestBuilder: true },
|
|
17
|
+
learning: { changeSummary: true, userVisibleChanges: true, internalChanges: true, mainFiles: true, explainedDiff: true, glossary: true, validationResults: true, riskAssessment: true, userActions: true, nextActionSuggestions: true, requestClarificationGuide: true, manualEditGuidance: true, requestBuilder: true },
|
|
18
|
+
detailed: { changeSummary: true, userVisibleChanges: true, internalChanges: true, mainFiles: true, explainedDiff: true, glossary: true, validationResults: true, riskAssessment: true, userActions: true, nextActionSuggestions: true, requestClarificationGuide: true, manualEditGuidance: true, requestBuilder: true },
|
|
19
|
+
};
|
|
20
|
+
const fallbackConfig = {
|
|
21
|
+
version: 1,
|
|
22
|
+
profile: 'balanced',
|
|
23
|
+
features: { ...PROFILE_FEATURES.balanced },
|
|
24
|
+
limits: { maxMainFiles: 5, maxGlossaryTerms: 3, compactReportMaxSentences: 12 },
|
|
25
|
+
mcp: { enabled: false },
|
|
26
|
+
usageMeasurement: { localCountersEnabled: false },
|
|
27
|
+
};
|
|
28
|
+
export async function exists(file) {
|
|
29
|
+
try {
|
|
30
|
+
await fs.access(file);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function readText(file) {
|
|
38
|
+
try {
|
|
39
|
+
return await fs.readFile(file, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function snapshot(file) {
|
|
46
|
+
const content = await readText(file);
|
|
47
|
+
return content === undefined ? { file, existed: false } : { file, existed: true, content };
|
|
48
|
+
}
|
|
49
|
+
export async function restore(snapshotValue) {
|
|
50
|
+
if (snapshotValue.existed) {
|
|
51
|
+
await fs.mkdir(path.dirname(snapshotValue.file), { recursive: true });
|
|
52
|
+
await fs.writeFile(snapshotValue.file, snapshotValue.content ?? '', 'utf8');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await fs.rm(snapshotValue.file, { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function writeTextSafely(file, content) {
|
|
59
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
60
|
+
const temporary = `${file}.beginner-bridge-tmp-${process.pid}`;
|
|
61
|
+
await fs.writeFile(temporary, content, 'utf8');
|
|
62
|
+
try {
|
|
63
|
+
await fs.rename(temporary, file);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
await fs.rm(temporary, { force: true });
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function backupFile(file) {
|
|
71
|
+
if (await exists(file))
|
|
72
|
+
await fs.copyFile(file, `${file}.previous`);
|
|
73
|
+
}
|
|
74
|
+
async function defaultConfig() {
|
|
75
|
+
const content = await readText(assets().defaultConfig);
|
|
76
|
+
if (!content)
|
|
77
|
+
return structuredClone(fallbackConfig);
|
|
78
|
+
try {
|
|
79
|
+
return normalizeConfig(JSON.parse(content));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return structuredClone(fallbackConfig);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function normalizeConfig(value) {
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
87
|
+
return structuredClone(fallbackConfig);
|
|
88
|
+
const input = value;
|
|
89
|
+
const profile = typeof input.profile === 'string' && PROFILES.includes(input.profile) ? input.profile : 'balanced';
|
|
90
|
+
const inputFeatures = input.features && typeof input.features === 'object' && !Array.isArray(input.features) ? input.features : {};
|
|
91
|
+
const features = Object.fromEntries(FEATURE_IDS.map((id) => [id, typeof inputFeatures[id] === 'boolean' ? inputFeatures[id] : PROFILE_FEATURES[profile][id]]));
|
|
92
|
+
const inputLimits = input.limits && typeof input.limits === 'object' && !Array.isArray(input.limits) ? input.limits : {};
|
|
93
|
+
const numberOr = (key, fallback) => typeof inputLimits[key] === 'number' && Number.isInteger(inputLimits[key]) ? inputLimits[key] : fallback;
|
|
94
|
+
const inputMcp = input.mcp && typeof input.mcp === 'object' && !Array.isArray(input.mcp) ? input.mcp : {};
|
|
95
|
+
const inputUsageMeasurement = input.usageMeasurement && typeof input.usageMeasurement === 'object' && !Array.isArray(input.usageMeasurement) ? input.usageMeasurement : {};
|
|
96
|
+
const inputVoice = input.voice && typeof input.voice === 'object' && !Array.isArray(input.voice) ? input.voice : {};
|
|
97
|
+
return {
|
|
98
|
+
version: 1,
|
|
99
|
+
profile,
|
|
100
|
+
features,
|
|
101
|
+
limits: { maxMainFiles: numberOr('maxMainFiles', 5), maxGlossaryTerms: numberOr('maxGlossaryTerms', 3), compactReportMaxSentences: numberOr('compactReportMaxSentences', 12) },
|
|
102
|
+
mcp: { enabled: inputMcp.enabled === true },
|
|
103
|
+
usageMeasurement: { localCountersEnabled: inputUsageMeasurement.localCountersEnabled === true },
|
|
104
|
+
...(typeof inputVoice.preset === 'string' ? { voice: { preset: inputVoice.preset } } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export async function readBridgeConfig(paths) {
|
|
108
|
+
const preferred = await readText(paths.configFile);
|
|
109
|
+
const raw = preferred ?? await readText(paths.legacyConfigFile);
|
|
110
|
+
const source = preferred !== undefined ? 'new' : raw !== undefined ? 'legacy' : 'default';
|
|
111
|
+
if (!raw)
|
|
112
|
+
return { config: await defaultConfig(), exists: false, valid: true, source };
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(raw);
|
|
115
|
+
const valid = parsed.version === 1 && typeof parsed.profile === 'string' && PROFILES.includes(parsed.profile);
|
|
116
|
+
return { config: normalizeConfig(parsed), exists: true, valid, source };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { config: await defaultConfig(), exists: true, valid: false, source };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export async function writeBridgeConfig(paths, config) {
|
|
123
|
+
await writeTextSafely(paths.configFile, `${JSON.stringify(config, null, 2)}\n`);
|
|
124
|
+
}
|
|
125
|
+
function markerPair(begin, end) {
|
|
126
|
+
return new RegExp(`${begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
127
|
+
}
|
|
128
|
+
function managedPatterns() {
|
|
129
|
+
return [
|
|
130
|
+
markerPair(BEGIN_MARKER, END_MARKER),
|
|
131
|
+
markerPair(PREVIOUS_BEGIN_MARKER, PREVIOUS_END_MARKER),
|
|
132
|
+
markerPair(LEGACY_BEGIN_MARKER, LEGACY_END_MARKER),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
function managedBlocks(content) {
|
|
136
|
+
return managedPatterns().flatMap((pattern) => content.match(pattern)?.[0] ?? []);
|
|
137
|
+
}
|
|
138
|
+
function withoutManagedBlocks(content) {
|
|
139
|
+
return managedPatterns().reduce((text, pattern) => text.replace(pattern, ''), content);
|
|
140
|
+
}
|
|
141
|
+
function withoutCanonicalBlock(content) {
|
|
142
|
+
return content.replace(markerPair(BEGIN_MARKER, END_MARKER), '');
|
|
143
|
+
}
|
|
144
|
+
function hasMcpKey(content, key) {
|
|
145
|
+
return new RegExp(`^\\s*\\[mcp_servers\\.${key}\\]\\s*$`, 'm').test(content);
|
|
146
|
+
}
|
|
147
|
+
function hasJuTellMcpEvidence(content, key) {
|
|
148
|
+
const keyPattern = new RegExp(`^\\s*\\[mcp_servers\\.${key}\\]\\s*$`, 'm');
|
|
149
|
+
const match = content.match(keyPattern);
|
|
150
|
+
if (!match || match.index === undefined)
|
|
151
|
+
return false;
|
|
152
|
+
const slice = content.slice(match.index, match.index + 1200);
|
|
153
|
+
// JuTell's server always contains `assets/mcp-server` or `apps/mcp-server` in args (see buildMcpBlock)
|
|
154
|
+
// Unrelated custom entries (e.g. jira, other, even a custom `jutell` pointing to `not-mcp-server.js`) do not –
|
|
155
|
+
// this prevents broadly adopting arbitrary unmarked MCP entries.
|
|
156
|
+
return /(?:assets|apps)[\\/]mcp-server/i.test(slice);
|
|
157
|
+
}
|
|
158
|
+
function tomlString(value) {
|
|
159
|
+
return JSON.stringify(value.replaceAll('\\', '/'));
|
|
160
|
+
}
|
|
161
|
+
export function buildMcpBlock(scope, packageRoot, enabled) {
|
|
162
|
+
const serverEntry = path.join(packageRoot, 'assets', 'mcp-server', 'index.js');
|
|
163
|
+
const lines = [BEGIN_MARKER, `[mcp_servers.${CANONICAL_MCP_KEY}]`, `command = ${tomlString(process.execPath)}`, `args = [${tomlString(serverEntry)}]`];
|
|
164
|
+
if (scope.scope === 'project')
|
|
165
|
+
lines.push('cwd = "."');
|
|
166
|
+
lines.push(`enabled = ${enabled ? 'true' : 'false'}`, 'required = false', 'default_tools_approval_mode = "prompt"', END_MARKER);
|
|
167
|
+
return lines.join('\n');
|
|
168
|
+
}
|
|
169
|
+
export async function readCodexRegistration(paths, packageRoot, enabled) {
|
|
170
|
+
const content = await readText(paths.codexConfigFile) ?? '';
|
|
171
|
+
const blocks = managedBlocks(content);
|
|
172
|
+
const canonicalManaged = blocks.find((block) => hasMcpKey(block, CANONICAL_MCP_KEY));
|
|
173
|
+
const legacyManaged = blocks.find((block) => hasMcpKey(block, LEGACY_MCP_KEY));
|
|
174
|
+
const canonicalRegistered = hasMcpKey(content, CANONICAL_MCP_KEY);
|
|
175
|
+
const legacyRegistered = hasMcpKey(content, LEGACY_MCP_KEY);
|
|
176
|
+
const canonicalHeuristic = !canonicalManaged && canonicalRegistered && hasJuTellMcpEvidence(content, CANONICAL_MCP_KEY);
|
|
177
|
+
const legacyHeuristic = !legacyManaged && legacyRegistered && hasJuTellMcpEvidence(content, LEGACY_MCP_KEY);
|
|
178
|
+
const registered = Boolean(canonicalManaged || legacyManaged || canonicalHeuristic || legacyHeuristic);
|
|
179
|
+
const conflict = !registered && (canonicalRegistered || legacyRegistered);
|
|
180
|
+
const enabledFlag = (() => {
|
|
181
|
+
if (canonicalManaged)
|
|
182
|
+
return /^\s*enabled\s*=\s*true\s*$/m.test(canonicalManaged);
|
|
183
|
+
if (canonicalHeuristic) {
|
|
184
|
+
const keyPattern = new RegExp(`^\\s*\\[mcp_servers\\.${CANONICAL_MCP_KEY}\\]\\s*$`, 'm');
|
|
185
|
+
const m = content.match(keyPattern);
|
|
186
|
+
if (m && m.index !== undefined) {
|
|
187
|
+
const slice = content.slice(m.index, m.index + 1200);
|
|
188
|
+
return /^\s*enabled\s*=\s*true\s*$/m.test(slice);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (legacyManaged)
|
|
192
|
+
return false;
|
|
193
|
+
if (legacyHeuristic) {
|
|
194
|
+
const keyPattern = new RegExp(`^\\s*\\[mcp_servers\\.${LEGACY_MCP_KEY}\\]\\s*$`, 'm');
|
|
195
|
+
const m = content.match(keyPattern);
|
|
196
|
+
if (m && m.index !== undefined) {
|
|
197
|
+
const slice = content.slice(m.index, m.index + 1200);
|
|
198
|
+
// legacy-only state is treated as registered but not enabled for canonical;
|
|
199
|
+
// status warning will guide to `jutell use codex` to add canonical.
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
})();
|
|
205
|
+
return {
|
|
206
|
+
content,
|
|
207
|
+
exists: content.length > 0,
|
|
208
|
+
registered,
|
|
209
|
+
conflict,
|
|
210
|
+
enabled: enabledFlag,
|
|
211
|
+
canonicalRegistered,
|
|
212
|
+
legacyRegistered,
|
|
213
|
+
bothRegistered: canonicalRegistered && legacyRegistered,
|
|
214
|
+
preview: buildMcpBlock(paths, packageRoot, enabled),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
export async function registerMcp(paths, packageRoot, enabled) {
|
|
218
|
+
const current = await readCodexRegistration(paths, packageRoot, enabled);
|
|
219
|
+
if (current.conflict)
|
|
220
|
+
throw new Error('Codex 설정에 같은 이름의 관리되지 않는 MCP 항목이 있어 자동 변경하지 않았습니다.');
|
|
221
|
+
if (current.canonicalRegistered && current.enabled === enabled)
|
|
222
|
+
return current;
|
|
223
|
+
await backupFile(paths.codexConfigFile);
|
|
224
|
+
const withoutManaged = withoutCanonicalBlock(current.content).replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
225
|
+
const next = `${withoutManaged}${withoutManaged ? '\n\n' : ''}${current.preview}\n`;
|
|
226
|
+
await writeTextSafely(paths.codexConfigFile, next);
|
|
227
|
+
return readCodexRegistration(paths, packageRoot, enabled);
|
|
228
|
+
}
|
|
229
|
+
export async function removeMcp(paths, packageRoot) {
|
|
230
|
+
const current = await readCodexRegistration(paths, packageRoot, false);
|
|
231
|
+
if (current.conflict)
|
|
232
|
+
throw new Error('관리되지 않는 같은 이름의 MCP 항목은 자동으로 제거하지 않습니다.');
|
|
233
|
+
if (!current.registered)
|
|
234
|
+
return current;
|
|
235
|
+
await backupFile(paths.codexConfigFile);
|
|
236
|
+
const next = withoutManagedBlocks(current.content).replace(/\n{3,}/g, '\n\n').trim();
|
|
237
|
+
await writeTextSafely(paths.codexConfigFile, next ? `${next}\n` : '');
|
|
238
|
+
return readCodexRegistration(paths, packageRoot, false);
|
|
239
|
+
}
|
|
240
|
+
export async function readVersionInfo() {
|
|
241
|
+
const content = await readText(assets().version);
|
|
242
|
+
const fallback = { cli: '0.2.1', skill: '확인 필요', mcp: '0.1.0', admin: '0.1.0' };
|
|
243
|
+
if (!content)
|
|
244
|
+
return fallback;
|
|
245
|
+
try {
|
|
246
|
+
return JSON.parse(content);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return fallback;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export function parseSkillVersion(skillText) {
|
|
253
|
+
if (!skillText)
|
|
254
|
+
return undefined;
|
|
255
|
+
const match = skillText.match(/jutellSkillVersion\s*:\s*["']?([0-9A-Za-z.\-]+)/);
|
|
256
|
+
return match ? match[1] : undefined;
|
|
257
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export function packageRoot() {
|
|
5
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
6
|
+
}
|
|
7
|
+
export function assets() {
|
|
8
|
+
const root = path.join(packageRoot(), 'assets');
|
|
9
|
+
return {
|
|
10
|
+
root,
|
|
11
|
+
skill: path.join(root, 'skill'),
|
|
12
|
+
mcpServer: path.join(root, 'mcp-server'),
|
|
13
|
+
localAdmin: path.join(root, 'local-admin'),
|
|
14
|
+
localAdminServer: path.join(root, 'local-admin-server.js'),
|
|
15
|
+
defaultConfig: path.join(root, 'default-config.json'),
|
|
16
|
+
version: path.join(root, 'version.json'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function codexHome() {
|
|
20
|
+
const override = process.env.CODEX_HOME;
|
|
21
|
+
return path.resolve(override && override.trim() ? override : path.join(userHome(), '.codex'));
|
|
22
|
+
}
|
|
23
|
+
export function opencodeConfigDir() {
|
|
24
|
+
return path.join(userHome(), '.config', 'opencode');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Real Claude Code CLI reads its own profile (settings, credentials, and
|
|
28
|
+
* `.claude.json`) from `$CLAUDE_CONFIG_DIR` when set, otherwise the user's
|
|
29
|
+
* home directory directly (verified: `.claude.json` sits at
|
|
30
|
+
* `<CLAUDE_CONFIG_DIR>/.claude.json`, not inside a `.claude/` subfolder).
|
|
31
|
+
* `userHome()` is used as the fallback base (not raw `os.homedir()`) purely
|
|
32
|
+
* so JuTell's own JUTELL_HOME/BEGINNER_BRIDGE_HOME test-isolation override
|
|
33
|
+
* also isolates Claude by default; every subprocess call this CLI makes to
|
|
34
|
+
* the real `claude` binary explicitly passes CLAUDE_CONFIG_DIR set to this
|
|
35
|
+
* same value, so the two can never disagree about which file is meant.
|
|
36
|
+
*/
|
|
37
|
+
export function claudeHome() {
|
|
38
|
+
const override = process.env.CLAUDE_CONFIG_DIR;
|
|
39
|
+
return path.resolve(override && override.trim() ? override : userHome());
|
|
40
|
+
}
|
|
41
|
+
export function userHome() {
|
|
42
|
+
const override = process.env.JUTELL_HOME ?? process.env.BEGINNER_BRIDGE_HOME;
|
|
43
|
+
return path.resolve(override && override.trim() ? override : os.homedir());
|
|
44
|
+
}
|
|
45
|
+
export function resolveScope(scope, cwd = process.cwd()) {
|
|
46
|
+
if (scope === 'global') {
|
|
47
|
+
const home = userHome();
|
|
48
|
+
return {
|
|
49
|
+
scope,
|
|
50
|
+
targetRoot: home,
|
|
51
|
+
skillRoot: path.join(home, '.agents', 'skills', 'beginner-bridge'),
|
|
52
|
+
configFile: path.join(home, '.jutell.json'),
|
|
53
|
+
legacyConfigFile: path.join(home, '.beginner-bridge.json'),
|
|
54
|
+
codexConfigFile: path.join(codexHome(), 'config.toml'),
|
|
55
|
+
opencodeConfigFile: path.join(opencodeConfigDir(), 'opencode.json'),
|
|
56
|
+
claudeConfigFile: path.join(claudeHome(), '.claude.json'),
|
|
57
|
+
dataRoot: path.join(home, '.jutell-local'),
|
|
58
|
+
legacyDataRoot: path.join(home, '.beginner-bridge-local'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const projectRoot = path.resolve(cwd);
|
|
62
|
+
return {
|
|
63
|
+
scope,
|
|
64
|
+
targetRoot: projectRoot,
|
|
65
|
+
skillRoot: path.join(projectRoot, '.agents', 'skills', 'beginner-bridge'),
|
|
66
|
+
configFile: path.join(projectRoot, '.jutell.json'),
|
|
67
|
+
legacyConfigFile: path.join(projectRoot, '.beginner-bridge.json'),
|
|
68
|
+
codexConfigFile: path.join(projectRoot, '.codex', 'config.toml'),
|
|
69
|
+
opencodeConfigFile: path.join(projectRoot, 'opencode.json'),
|
|
70
|
+
// Claude's own file location never varies by JuTell's scope choice -
|
|
71
|
+
// only *which key inside it* (top-level `mcpServers` for global/user,
|
|
72
|
+
// `projects[targetRoot].mcpServers` for project/local) does. See
|
|
73
|
+
// installer/claude.ts.
|
|
74
|
+
claudeConfigFile: path.join(claudeHome(), '.claude.json'),
|
|
75
|
+
dataRoot: path.join(projectRoot, '.jutell-local'),
|
|
76
|
+
legacyDataRoot: path.join(projectRoot, '.beginner-bridge-local'),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function safeLocation(scope, kind) {
|
|
80
|
+
if (kind === 'codex')
|
|
81
|
+
return '사용자 Codex 설정 (전역)';
|
|
82
|
+
if (scope === 'global')
|
|
83
|
+
return '사용자 전역 설정';
|
|
84
|
+
return '현재 프로젝트/.jutell.json';
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Real Codex CLI only reads MCP server definitions from its global
|
|
88
|
+
* `$CODEX_HOME/config.toml` — it never consumes a project-scoped
|
|
89
|
+
* `<project>/.codex/config.toml` (verified empirically: `codex mcp list`
|
|
90
|
+
* returns no servers when only a project-scope file exists). So any
|
|
91
|
+
* ScopePaths used to read/write/remove a Codex MCP registration must
|
|
92
|
+
* always target the real global file, regardless of the invocation's
|
|
93
|
+
* `--project`/`--global` scope — otherwise JuTell would report success
|
|
94
|
+
* while Codex never actually sees the server. `.jutell.json`, the Skill,
|
|
95
|
+
* and the AGENTS.md block are unaffected and keep following the
|
|
96
|
+
* requested scope; only the Codex MCP file location is forced.
|
|
97
|
+
*/
|
|
98
|
+
export function codexScopedPaths(paths) {
|
|
99
|
+
return { ...paths, scope: 'global', codexConfigFile: path.join(codexHome(), 'config.toml') };
|
|
100
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readText, writeTextSafely } from '../config/managed.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const JUTELL_AGENTS_BEGIN = '<!-- BEGIN JUTELL MANAGED BLOCK -->';
|
|
4
|
+
export const JUTELL_AGENTS_END = '<!-- END JUTELL MANAGED BLOCK -->';
|
|
5
|
+
const managedBlock = `${JUTELL_AGENTS_BEGIN}
|
|
6
|
+
## JuTell
|
|
7
|
+
|
|
8
|
+
- 코드 또는 문서 변경 후 \`.agents/skills/beginner-bridge/SKILL.md\`와 \`.jutell.json\`을 먼저 읽습니다. 소유자 대상 구현/보고 전에는 JuTell 보고 규칙을 먼저 적용해 최종 답변을 작성합니다.
|
|
9
|
+
- JuTell MCP가 보이면 canonical \`jutell\` 서버를 사용합니다. \`jutell\`과 legacy \`beginner_bridge\`가 모두 보이면 \`jutell\`을 우선하고 \`beginner_bridge\`는 호환용으로만 사용합니다.
|
|
10
|
+
- 확인하지 않은 결과를 사실처럼 표현하지 않습니다.
|
|
11
|
+
- 비밀정보를 명령 출력이나 보고서에 포함하지 않습니다.
|
|
12
|
+
- 외부 전송 없이 현재 프로젝트의 로컬 설정을 기준으로 작업합니다.
|
|
13
|
+
${JUTELL_AGENTS_END}`;
|
|
14
|
+
function markerPattern() {
|
|
15
|
+
return new RegExp(`${JUTELL_AGENTS_BEGIN}[\\s\\S]*?${JUTELL_AGENTS_END}`, 'm');
|
|
16
|
+
}
|
|
17
|
+
export function agentsFile(projectRoot) {
|
|
18
|
+
return path.join(projectRoot, 'AGENTS.md');
|
|
19
|
+
}
|
|
20
|
+
export async function hasJuTellAgentsBlock(projectRoot) {
|
|
21
|
+
const content = await readText(agentsFile(projectRoot));
|
|
22
|
+
return Boolean(content && markerPattern().test(content));
|
|
23
|
+
}
|
|
24
|
+
export async function ensureJuTellAgentsBlock(projectRoot) {
|
|
25
|
+
const file = agentsFile(projectRoot);
|
|
26
|
+
const current = await readText(file) ?? '';
|
|
27
|
+
const next = markerPattern().test(current)
|
|
28
|
+
? current.replace(markerPattern(), managedBlock).replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
|
|
29
|
+
: `${current.trimEnd()}${current.trimEnd() ? '\n\n' : ''}${managedBlock}\n`;
|
|
30
|
+
if (next !== current)
|
|
31
|
+
await writeTextSafely(file, next);
|
|
32
|
+
return { changed: next !== current };
|
|
33
|
+
}
|
|
34
|
+
export async function removeJuTellAgentsBlock(projectRoot) {
|
|
35
|
+
const file = agentsFile(projectRoot);
|
|
36
|
+
const current = await readText(file);
|
|
37
|
+
if (!current || !markerPattern().test(current))
|
|
38
|
+
return { changed: false };
|
|
39
|
+
const next = current.replace(markerPattern(), '').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
40
|
+
await writeTextSafely(file, next ? `${next}\n` : '');
|
|
41
|
+
return { changed: true };
|
|
42
|
+
}
|