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,160 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readText } from '../config/managed.js';
|
|
4
|
+
import { claudeHome } from '../config/paths.js';
|
|
5
|
+
export const CLAUDE_MCP_KEY = 'jutell';
|
|
6
|
+
function normalizeForCompare(value) {
|
|
7
|
+
return value.replace(/\\/g, '/').toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Real Claude Code has no per-project `.codex`-style file: `local` scope
|
|
11
|
+
* (JuTell's `--project`, the default) and `user` scope (JuTell `--global`)
|
|
12
|
+
* both live in the *same* `.claude.json`, keyed either by the exact
|
|
13
|
+
* project path (`projects[path].mcpServers`) or at the top level
|
|
14
|
+
* (`mcpServers`). Verified empirically with an isolated `CLAUDE_CONFIG_DIR`
|
|
15
|
+
* across all three of Claude's own scopes (local/user/project); `project`
|
|
16
|
+
* (`.mcp.json`, git-shared) was not chosen for JuTell because Claude leaves
|
|
17
|
+
* servers registered there `⏸ Pending approval` until a human approves them
|
|
18
|
+
* in an interactive session - that would violate "no manual config
|
|
19
|
+
* hacking" for the very first connection.
|
|
20
|
+
*/
|
|
21
|
+
function claudeScopeFor(paths) {
|
|
22
|
+
return paths.scope === 'global' ? 'user' : 'local';
|
|
23
|
+
}
|
|
24
|
+
async function readClaudeConfig(paths) {
|
|
25
|
+
const text = await readText(paths.claudeConfigFile);
|
|
26
|
+
if (!text || !text.trim())
|
|
27
|
+
return {};
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(text);
|
|
30
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function findMcpServers(config, paths) {
|
|
37
|
+
if (claudeScopeFor(paths) === 'user') {
|
|
38
|
+
const top = config.mcpServers;
|
|
39
|
+
return top && typeof top === 'object' && !Array.isArray(top) ? top : undefined;
|
|
40
|
+
}
|
|
41
|
+
const projects = config.projects;
|
|
42
|
+
if (!projects || typeof projects !== 'object' || Array.isArray(projects))
|
|
43
|
+
return undefined;
|
|
44
|
+
const target = normalizeForCompare(paths.targetRoot);
|
|
45
|
+
for (const [key, value] of Object.entries(projects)) {
|
|
46
|
+
if (normalizeForCompare(key) !== target)
|
|
47
|
+
continue;
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
const mcp = value.mcpServers;
|
|
51
|
+
return mcp && typeof mcp === 'object' && !Array.isArray(mcp) ? mcp : undefined;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function expectedCommand(packageRoot) {
|
|
56
|
+
return { command: process.execPath, args: [path.join(packageRoot, 'assets', 'mcp-server', 'index.js')] };
|
|
57
|
+
}
|
|
58
|
+
function commandMatches(entry, packageRoot) {
|
|
59
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
60
|
+
return false;
|
|
61
|
+
const value = entry;
|
|
62
|
+
const { command } = expectedCommand(packageRoot);
|
|
63
|
+
if (value.command !== command)
|
|
64
|
+
return false;
|
|
65
|
+
const args = Array.isArray(value.args) ? value.args : [];
|
|
66
|
+
return args.some((a) => typeof a === 'string' && a.includes('mcp-server'));
|
|
67
|
+
}
|
|
68
|
+
export function buildClaudePreview(claudeScope, packageRoot) {
|
|
69
|
+
const { command, args } = expectedCommand(packageRoot);
|
|
70
|
+
return JSON.stringify({ scope: claudeScope, type: 'stdio', command, args }, null, 2);
|
|
71
|
+
}
|
|
72
|
+
export async function readClaudeRegistration(paths, packageRoot, _enabled) {
|
|
73
|
+
const claudeScope = claudeScopeFor(paths);
|
|
74
|
+
const config = await readClaudeConfig(paths);
|
|
75
|
+
const servers = config ? findMcpServers(config, paths) : undefined;
|
|
76
|
+
const entry = servers?.[CLAUDE_MCP_KEY];
|
|
77
|
+
const registered = entry !== undefined;
|
|
78
|
+
// The `jutell` key is reserved across every JuTell provider adapter, so
|
|
79
|
+
// any existing entry under that name is treated as ours to manage
|
|
80
|
+
// (refreshed in place if its command/args drifted) rather than a
|
|
81
|
+
// conflict - unlike Codex/OpenCode, Claude has no historical legacy key
|
|
82
|
+
// to coexist with here (this is a brand-new adapter; providers.ts had it
|
|
83
|
+
// marked `planned` until this cycle, so no prior Claude JuTell state
|
|
84
|
+
// exists to preserve).
|
|
85
|
+
return {
|
|
86
|
+
file: paths.claudeConfigFile,
|
|
87
|
+
claudeScope,
|
|
88
|
+
exists: Boolean(config && Object.keys(config).length > 0),
|
|
89
|
+
registered,
|
|
90
|
+
conflict: false,
|
|
91
|
+
enabled: registered,
|
|
92
|
+
canonicalRegistered: registered,
|
|
93
|
+
legacyRegistered: false,
|
|
94
|
+
bothRegistered: false,
|
|
95
|
+
preview: buildClaudePreview(claudeScope, packageRoot),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Windows can only launch the `claude` npm shim (a .cmd file) through a
|
|
99
|
+
// shell, but `execFileSync`'s shell mode does not itself quote arguments -
|
|
100
|
+
// it just space-joins them, so any argument containing a space (a `node.exe`
|
|
101
|
+
// path under "Program Files", for instance) silently gets cut at the first
|
|
102
|
+
// space. Quote every argument ourselves before it reaches cmd.exe.
|
|
103
|
+
function quoteForWindowsShell(value) {
|
|
104
|
+
if (value === '')
|
|
105
|
+
return '""';
|
|
106
|
+
if (!/[\s"^&|<>()]/.test(value))
|
|
107
|
+
return value;
|
|
108
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
109
|
+
}
|
|
110
|
+
function runClaude(args, paths) {
|
|
111
|
+
const isWindows = process.platform === 'win32';
|
|
112
|
+
return execFileSync('claude', isWindows ? args.map(quoteForWindowsShell) : args, {
|
|
113
|
+
cwd: paths.targetRoot,
|
|
114
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: claudeHome() },
|
|
115
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
shell: isWindows,
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export async function registerClaudeMcp(paths, packageRoot, enabled) {
|
|
122
|
+
const current = await readClaudeRegistration(paths, packageRoot, enabled);
|
|
123
|
+
const config = await readClaudeConfig(paths);
|
|
124
|
+
if (config === undefined)
|
|
125
|
+
throw new Error('Claude Code 설정 파일을 읽지 못해 자동 변경하지 않았습니다.');
|
|
126
|
+
const servers = findMcpServers(config, paths);
|
|
127
|
+
const claudeScope = current.claudeScope;
|
|
128
|
+
if (current.registered && commandMatches(servers?.[CLAUDE_MCP_KEY], packageRoot))
|
|
129
|
+
return current; // idempotent, already correct
|
|
130
|
+
if (current.registered) {
|
|
131
|
+
// Existing `jutell` entry points somewhere else (stale reinstall) -
|
|
132
|
+
// refresh it in place rather than leaving two inconsistent states.
|
|
133
|
+
try {
|
|
134
|
+
runClaude(['mcp', 'remove', '-s', claudeScope, CLAUDE_MCP_KEY], paths);
|
|
135
|
+
}
|
|
136
|
+
catch { /* fall through to add */ }
|
|
137
|
+
}
|
|
138
|
+
const { command, args } = expectedCommand(packageRoot);
|
|
139
|
+
try {
|
|
140
|
+
runClaude(['mcp', 'add', '-s', claudeScope, CLAUDE_MCP_KEY, '--', command, ...args], paths);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
throw new Error(`Claude Code MCP 등록에 실패했습니다: ${message}`);
|
|
145
|
+
}
|
|
146
|
+
return readClaudeRegistration(paths, packageRoot, enabled);
|
|
147
|
+
}
|
|
148
|
+
export async function removeClaudeMcp(paths, packageRoot) {
|
|
149
|
+
const current = await readClaudeRegistration(paths, packageRoot, false);
|
|
150
|
+
if (!current.registered)
|
|
151
|
+
return current;
|
|
152
|
+
try {
|
|
153
|
+
runClaude(['mcp', 'remove', '-s', current.claudeScope, CLAUDE_MCP_KEY], paths);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
157
|
+
throw new Error(`Claude Code MCP 제거에 실패했습니다: ${message}`);
|
|
158
|
+
}
|
|
159
|
+
return readClaudeRegistration(paths, packageRoot, false);
|
|
160
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { backupFile, readBridgeConfig, writeBridgeConfig, writeTextSafely } from '../config/managed.js';
|
|
3
|
+
async function readObject(paths) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await fs.readFile(paths.configFile, 'utf8');
|
|
6
|
+
const value = JSON.parse(raw);
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function ensureBridgeConfig(paths, profile) {
|
|
14
|
+
const loaded = await readBridgeConfig(paths);
|
|
15
|
+
const current = await readObject(paths);
|
|
16
|
+
if (!current || !loaded.valid) {
|
|
17
|
+
const next = { ...loaded.config, ...(profile ? { profile } : {}) };
|
|
18
|
+
await writeBridgeConfig(paths, next);
|
|
19
|
+
return { config: next, created: true, replacedInvalid: loaded.exists && !loaded.valid };
|
|
20
|
+
}
|
|
21
|
+
const next = {
|
|
22
|
+
...current,
|
|
23
|
+
version: 1,
|
|
24
|
+
...(profile ? { profile } : {}),
|
|
25
|
+
mcp: (() => { const m = current.mcp; return { enabled: m?.enabled === true }; })(),
|
|
26
|
+
};
|
|
27
|
+
const before = JSON.stringify(current, null, 2);
|
|
28
|
+
const after = JSON.stringify(next, null, 2);
|
|
29
|
+
if (before !== after)
|
|
30
|
+
await writeTextSafely(paths.configFile, `${after}\n`);
|
|
31
|
+
return { config: next, created: false, replacedInvalid: false };
|
|
32
|
+
}
|
|
33
|
+
export async function setMcpEnabled(paths, enabled) {
|
|
34
|
+
const loaded = await readBridgeConfig(paths);
|
|
35
|
+
const current = await readObject(paths);
|
|
36
|
+
const next = { ...(current ?? loaded.config), version: 1, mcp: { ...(loaded.config.mcp ?? { enabled: false }), enabled } };
|
|
37
|
+
await writeBridgeConfig(paths, next);
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
export async function setMcpDisabled(paths) {
|
|
41
|
+
return setMcpEnabled(paths, false);
|
|
42
|
+
}
|
|
43
|
+
export async function backupBridgeConfig(paths) {
|
|
44
|
+
await backupFile(paths.configFile);
|
|
45
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { backupFile, exists, readText, writeTextSafely } from '../config/managed.js';
|
|
5
|
+
export const OPENCODE_BEGIN_MARKER = 'BEGIN JUTELL MANAGED BLOCK';
|
|
6
|
+
export const OPENCODE_END_MARKER = 'END JUTELL MANAGED BLOCK';
|
|
7
|
+
export const OPENCODE_MCP_KEY = 'jutell';
|
|
8
|
+
export const LEGACY_OPENCODE_MCP_KEY = 'beginner_bridge';
|
|
9
|
+
function stripJsoncComments(text) {
|
|
10
|
+
let out = '';
|
|
11
|
+
let inString = false;
|
|
12
|
+
let escaped = false;
|
|
13
|
+
let inLine = false;
|
|
14
|
+
let inBlock = false;
|
|
15
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
16
|
+
const c = text[i];
|
|
17
|
+
const next = text[i + 1];
|
|
18
|
+
if (inBlock) {
|
|
19
|
+
if (c === '*' && next === '/') {
|
|
20
|
+
inBlock = false;
|
|
21
|
+
out += ' ';
|
|
22
|
+
i += 1;
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
out += c === '\n' ? '\n' : ' ';
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (inLine) {
|
|
29
|
+
if (c === '\n') {
|
|
30
|
+
inLine = false;
|
|
31
|
+
out += '\n';
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
out += ' ';
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (inString) {
|
|
38
|
+
out += c;
|
|
39
|
+
if (escaped)
|
|
40
|
+
escaped = false;
|
|
41
|
+
else if (c === '\\')
|
|
42
|
+
escaped = true;
|
|
43
|
+
else if (c === '"')
|
|
44
|
+
inString = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (c === '"') {
|
|
48
|
+
inString = true;
|
|
49
|
+
out += c;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '/' && next === '/') {
|
|
53
|
+
inLine = true;
|
|
54
|
+
out += ' ';
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '/' && next === '*') {
|
|
59
|
+
inBlock = true;
|
|
60
|
+
out += ' ';
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
out += c;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function tryParseJson(text) {
|
|
69
|
+
const trimmed = (text ?? '').trim();
|
|
70
|
+
if (!trimmed)
|
|
71
|
+
return {};
|
|
72
|
+
try {
|
|
73
|
+
const cleaned = stripJsoncComments(trimmed).replace(/,\s*([}\]])/g, '$1');
|
|
74
|
+
const value = JSON.parse(cleaned);
|
|
75
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function opencodeDetected() {
|
|
82
|
+
const result = spawnSync('opencode', ['--version'], { stdio: 'ignore', windowsHide: true, shell: process.platform === 'win32' || undefined });
|
|
83
|
+
return result.status === 0 && !result.error;
|
|
84
|
+
}
|
|
85
|
+
export async function resolveOpenCodeConfigFile(paths) {
|
|
86
|
+
const directory = path.dirname(paths.opencodeConfigFile);
|
|
87
|
+
for (const name of ['opencode.jsonc', 'opencode.json']) {
|
|
88
|
+
const file = path.join(directory, name);
|
|
89
|
+
if (await exists(file))
|
|
90
|
+
return file;
|
|
91
|
+
}
|
|
92
|
+
return paths.opencodeConfigFile;
|
|
93
|
+
}
|
|
94
|
+
function serverEntry(packageRoot) {
|
|
95
|
+
return path.join(packageRoot, 'assets', 'mcp-server', 'index.js');
|
|
96
|
+
}
|
|
97
|
+
export function buildOpenCodeBlock(packageRoot, enabled) {
|
|
98
|
+
const entry = { type: 'local', command: [process.execPath, serverEntry(packageRoot)], enabled, cwd: '.' };
|
|
99
|
+
return JSON.stringify(entry, null, 2);
|
|
100
|
+
}
|
|
101
|
+
function indentLines(value, prefix) {
|
|
102
|
+
return value.replace(/\n/g, `\n${prefix}`);
|
|
103
|
+
}
|
|
104
|
+
function serializeWithManaged(config, enabled, packageRoot) {
|
|
105
|
+
const copy = { ...config };
|
|
106
|
+
const rawMcp = copy.mcp && typeof copy.mcp === 'object' && !Array.isArray(copy.mcp) ? copy.mcp : {};
|
|
107
|
+
const others = Object.entries(rawMcp).filter(([key]) => key !== OPENCODE_MCP_KEY);
|
|
108
|
+
const lines = ['{'];
|
|
109
|
+
for (const [key, value] of Object.entries(copy)) {
|
|
110
|
+
if (key === 'mcp')
|
|
111
|
+
continue;
|
|
112
|
+
lines.push(` ${JSON.stringify(key)}: ${indentLines(JSON.stringify(value, null, 2), ' ')},`);
|
|
113
|
+
}
|
|
114
|
+
lines.push(' "mcp": {');
|
|
115
|
+
for (const [key, value] of others) {
|
|
116
|
+
lines.push(` ${JSON.stringify(key)}: ${indentLines(JSON.stringify(value, null, 2), ' ')},`);
|
|
117
|
+
}
|
|
118
|
+
lines.push(` // ${OPENCODE_BEGIN_MARKER}`);
|
|
119
|
+
lines.push(` ${JSON.stringify(OPENCODE_MCP_KEY)}: ${indentLines(buildOpenCodeBlock(packageRoot, enabled), ' ')},`);
|
|
120
|
+
lines.push(` // ${OPENCODE_END_MARKER}`);
|
|
121
|
+
lines.push(' }');
|
|
122
|
+
lines.push('}');
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
export async function readOpenCodeRegistration(paths, packageRoot, enabled) {
|
|
126
|
+
const file = await resolveOpenCodeConfigFile(paths);
|
|
127
|
+
const text = await readText(file) ?? '';
|
|
128
|
+
const parsed = tryParseJson(text);
|
|
129
|
+
const rawMcp = parsed?.mcp && typeof parsed.mcp === 'object' && !Array.isArray(parsed.mcp) ? parsed.mcp : {};
|
|
130
|
+
const canonicalEntry = rawMcp[OPENCODE_MCP_KEY];
|
|
131
|
+
const legacyEntry = rawMcp[LEGACY_OPENCODE_MCP_KEY];
|
|
132
|
+
const begin = text.indexOf(`// ${OPENCODE_BEGIN_MARKER}`);
|
|
133
|
+
const end = text.indexOf(`// ${OPENCODE_END_MARKER}`);
|
|
134
|
+
const managedText = begin >= 0 && end > begin ? text.slice(begin, end) : '';
|
|
135
|
+
const canonicalManaged = managedText.includes(JSON.stringify(OPENCODE_MCP_KEY));
|
|
136
|
+
const legacyManaged = managedText.includes(JSON.stringify(LEGACY_OPENCODE_MCP_KEY));
|
|
137
|
+
const canonicalRegistered = canonicalEntry !== undefined;
|
|
138
|
+
const legacyRegistered = legacyEntry !== undefined;
|
|
139
|
+
function isJuTellEntry(entry) {
|
|
140
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
141
|
+
return false;
|
|
142
|
+
const cmd = entry.command;
|
|
143
|
+
if (!Array.isArray(cmd))
|
|
144
|
+
return false;
|
|
145
|
+
return cmd.some((c) => typeof c === 'string' && /(?:assets|apps)[\\/]mcp-server/i.test(c));
|
|
146
|
+
}
|
|
147
|
+
const canonicalHeuristic = !canonicalManaged && canonicalRegistered && isJuTellEntry(canonicalEntry);
|
|
148
|
+
const legacyHeuristic = !legacyManaged && legacyRegistered && isJuTellEntry(legacyEntry);
|
|
149
|
+
const registered = canonicalManaged || legacyManaged || canonicalHeuristic || legacyHeuristic;
|
|
150
|
+
const conflict = !registered && (canonicalRegistered || legacyRegistered);
|
|
151
|
+
const enabledFlag = (() => {
|
|
152
|
+
if (canonicalManaged && canonicalEntry && typeof canonicalEntry === 'object')
|
|
153
|
+
return canonicalEntry.enabled === true;
|
|
154
|
+
if (canonicalHeuristic && canonicalEntry && typeof canonicalEntry === 'object')
|
|
155
|
+
return canonicalEntry.enabled === true;
|
|
156
|
+
return false;
|
|
157
|
+
})();
|
|
158
|
+
return {
|
|
159
|
+
file,
|
|
160
|
+
exists: text.trim().length > 0,
|
|
161
|
+
registered,
|
|
162
|
+
conflict,
|
|
163
|
+
enabled: enabledFlag,
|
|
164
|
+
canonicalRegistered,
|
|
165
|
+
legacyRegistered,
|
|
166
|
+
bothRegistered: canonicalRegistered && legacyRegistered,
|
|
167
|
+
preview: serializeWithManaged(parsed ?? {}, enabled, packageRoot),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async function writeConfig(file, text) {
|
|
171
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
172
|
+
await writeTextSafely(file, text);
|
|
173
|
+
}
|
|
174
|
+
export async function registerOpenCodeMcp(paths, packageRoot, enabled) {
|
|
175
|
+
const current = await readOpenCodeRegistration(paths, packageRoot, enabled);
|
|
176
|
+
if (current.conflict)
|
|
177
|
+
throw new Error('OpenCode 설정에 같은 이름의 관리되지 않는 MCP 항목이 있어 자동 변경하지 않았습니다.');
|
|
178
|
+
if (current.canonicalRegistered && current.enabled === enabled)
|
|
179
|
+
return current;
|
|
180
|
+
const text = await readText(current.file) ?? '';
|
|
181
|
+
const parsed = tryParseJson(text);
|
|
182
|
+
if (text.trim() && !parsed)
|
|
183
|
+
throw new Error('OpenCode 설정 파일을 읽지 못해 자동 변경하지 않았습니다.');
|
|
184
|
+
const base = parsed ?? { $schema: 'https://opencode.ai/config.json' };
|
|
185
|
+
await backupFile(current.file);
|
|
186
|
+
await writeConfig(current.file, `${serializeWithManaged(base, enabled, packageRoot)}\n`);
|
|
187
|
+
return readOpenCodeRegistration(paths, packageRoot, enabled);
|
|
188
|
+
}
|
|
189
|
+
export async function setOpenCodeEnabled(paths, packageRoot, enabled) {
|
|
190
|
+
const current = await readOpenCodeRegistration(paths, packageRoot, enabled);
|
|
191
|
+
if (current.conflict || !current.registered)
|
|
192
|
+
return current;
|
|
193
|
+
if (!current.canonicalRegistered || current.enabled === enabled)
|
|
194
|
+
return current;
|
|
195
|
+
await backupFile(current.file);
|
|
196
|
+
const text = await readText(current.file) ?? '';
|
|
197
|
+
const parsed = tryParseJson(text);
|
|
198
|
+
if (!parsed)
|
|
199
|
+
throw new Error('OpenCode 설정 파일을 읽지 못해 자동 변경하지 않았습니다.');
|
|
200
|
+
const copy = { ...parsed };
|
|
201
|
+
const rawMcp = copy.mcp && typeof copy.mcp === 'object' && !Array.isArray(copy.mcp) ? copy.mcp : {};
|
|
202
|
+
const mcpCopy = { ...rawMcp };
|
|
203
|
+
delete mcpCopy[OPENCODE_MCP_KEY];
|
|
204
|
+
if (Object.keys(mcpCopy).length === 0)
|
|
205
|
+
delete copy.mcp;
|
|
206
|
+
else
|
|
207
|
+
copy.mcp = mcpCopy;
|
|
208
|
+
await writeConfig(current.file, `${serializeWithManaged(copy, enabled, packageRoot)}\n`);
|
|
209
|
+
return readOpenCodeRegistration(paths, packageRoot, enabled);
|
|
210
|
+
}
|
|
211
|
+
export async function removeOpenCodeMcp(paths, packageRoot) {
|
|
212
|
+
const current = await readOpenCodeRegistration(paths, packageRoot, false);
|
|
213
|
+
if (current.conflict)
|
|
214
|
+
throw new Error('관리되지 않는 같은 이름의 OpenCode MCP 항목은 자동으로 제거하지 않습니다.');
|
|
215
|
+
if (!current.registered)
|
|
216
|
+
return current;
|
|
217
|
+
await backupFile(current.file);
|
|
218
|
+
const text = await readText(current.file) ?? '';
|
|
219
|
+
const parsed = tryParseJson(text);
|
|
220
|
+
if (!parsed)
|
|
221
|
+
throw new Error('OpenCode 설정 파일을 읽지 못해 자동 변경하지 않았습니다.');
|
|
222
|
+
const copy = { ...parsed };
|
|
223
|
+
const rawMcp = copy.mcp && typeof copy.mcp === 'object' && !Array.isArray(copy.mcp) ? copy.mcp : {};
|
|
224
|
+
const begin = text.indexOf(`// ${OPENCODE_BEGIN_MARKER}`);
|
|
225
|
+
const end = text.indexOf(`// ${OPENCODE_END_MARKER}`);
|
|
226
|
+
const managedText = begin >= 0 && end > begin ? text.slice(begin, end) : '';
|
|
227
|
+
if (managedText.includes(JSON.stringify(OPENCODE_MCP_KEY)))
|
|
228
|
+
delete rawMcp[OPENCODE_MCP_KEY];
|
|
229
|
+
if (managedText.includes(JSON.stringify(LEGACY_OPENCODE_MCP_KEY)))
|
|
230
|
+
delete rawMcp[LEGACY_OPENCODE_MCP_KEY];
|
|
231
|
+
if (Object.keys(rawMcp).length === 0)
|
|
232
|
+
delete copy.mcp;
|
|
233
|
+
else
|
|
234
|
+
copy.mcp = rawMcp;
|
|
235
|
+
await writeConfig(current.file, `${JSON.stringify(copy, null, 2)}\n`);
|
|
236
|
+
return readOpenCodeRegistration(paths, packageRoot, false);
|
|
237
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const AGENT_PROVIDERS = [
|
|
2
|
+
{ id: 'codex', label: 'Codex', status: 'supported', description: '현재 실제 연결을 지원합니다.' },
|
|
3
|
+
{ id: 'opencode', label: 'OpenCode', status: 'beta', description: '로컬 stdio MCP 등록을 베타로 지원합니다.' },
|
|
4
|
+
{ id: 'claude-code', label: 'Claude Code', status: 'beta', description: 'MCP 등록을 베타로 지원합니다.', aliases: ['claude'] },
|
|
5
|
+
{ id: 'cline', label: 'Cline', status: 'planned', description: '연결 준비 중입니다.' },
|
|
6
|
+
];
|
|
7
|
+
export function findProvider(id) {
|
|
8
|
+
return AGENT_PROVIDERS.find((provider) => provider.id === id || provider.aliases?.includes(id));
|
|
9
|
+
}
|
|
10
|
+
export function supportedProviders() {
|
|
11
|
+
return AGENT_PROVIDERS.filter((provider) => provider.status !== 'planned');
|
|
12
|
+
}
|
|
13
|
+
export function supportedProviderNames() {
|
|
14
|
+
return supportedProviders().map((provider) => provider.label).join('와 ');
|
|
15
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exists, readText, writeTextSafely } from '../config/managed.js';
|
|
4
|
+
async function filesUnder(root, current = root) {
|
|
5
|
+
if (!(await exists(current)))
|
|
6
|
+
return [];
|
|
7
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
8
|
+
const files = [];
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const file = path.join(current, entry.name);
|
|
11
|
+
if (entry.isDirectory())
|
|
12
|
+
files.push(...await filesUnder(root, file));
|
|
13
|
+
else
|
|
14
|
+
files.push(path.relative(root, file));
|
|
15
|
+
}
|
|
16
|
+
return files;
|
|
17
|
+
}
|
|
18
|
+
export async function installSkill(source, destination) {
|
|
19
|
+
const conflicts = [];
|
|
20
|
+
const changed = [];
|
|
21
|
+
for (const relative of await filesUnder(source)) {
|
|
22
|
+
const sourceFile = path.join(source, relative);
|
|
23
|
+
const targetFile = path.join(destination, relative);
|
|
24
|
+
const sourceContent = await fs.readFile(sourceFile);
|
|
25
|
+
let targetContent;
|
|
26
|
+
try {
|
|
27
|
+
targetContent = await fs.readFile(targetFile);
|
|
28
|
+
}
|
|
29
|
+
catch { /* new file */ }
|
|
30
|
+
if (targetContent && !targetContent.equals(sourceContent)) {
|
|
31
|
+
conflicts.push(relative);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!targetContent) {
|
|
35
|
+
await fs.mkdir(path.dirname(targetFile), { recursive: true });
|
|
36
|
+
await fs.copyFile(sourceFile, targetFile);
|
|
37
|
+
changed.push(relative);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { conflicts, changed };
|
|
41
|
+
}
|
|
42
|
+
function manifestFile(paths) {
|
|
43
|
+
return path.join(paths.dataRoot, 'cli-install.json');
|
|
44
|
+
}
|
|
45
|
+
async function readManifest(paths) {
|
|
46
|
+
const raw = await readText(manifestFile(paths));
|
|
47
|
+
if (!raw)
|
|
48
|
+
return { skillFiles: [] };
|
|
49
|
+
try {
|
|
50
|
+
const value = JSON.parse(raw);
|
|
51
|
+
return { skillFiles: Array.isArray(value.skillFiles) ? value.skillFiles.filter((item) => typeof item === 'string') : [] };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { skillFiles: [] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function recordSkillFiles(paths, relativeFiles) {
|
|
58
|
+
if (relativeFiles.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
const current = await readManifest(paths);
|
|
61
|
+
await fs.mkdir(paths.dataRoot, { recursive: true });
|
|
62
|
+
await writeTextSafely(manifestFile(paths), `${JSON.stringify({ skillFiles: [...new Set([...current.skillFiles, ...relativeFiles])] }, null, 2)}\n`);
|
|
63
|
+
}
|
|
64
|
+
export async function removeManagedSkillFiles(source, destination, paths) {
|
|
65
|
+
const manifest = await readManifest(paths);
|
|
66
|
+
const removed = [];
|
|
67
|
+
for (const relative of manifest.skillFiles) {
|
|
68
|
+
const sourceFile = path.join(source, relative);
|
|
69
|
+
const targetFile = path.join(destination, relative);
|
|
70
|
+
try {
|
|
71
|
+
const [sourceContent, targetContent] = await Promise.all([fs.readFile(sourceFile), fs.readFile(targetFile)]);
|
|
72
|
+
if (!sourceContent.equals(targetContent))
|
|
73
|
+
continue;
|
|
74
|
+
await fs.rm(targetFile, { force: true });
|
|
75
|
+
removed.push(relative);
|
|
76
|
+
}
|
|
77
|
+
catch { /* already absent or user file differs */ }
|
|
78
|
+
}
|
|
79
|
+
for (const relative of [...manifest.skillFiles].reverse()) {
|
|
80
|
+
const directory = path.dirname(path.join(destination, relative));
|
|
81
|
+
try {
|
|
82
|
+
if ((await fs.readdir(directory)).length === 0)
|
|
83
|
+
await fs.rmdir(directory);
|
|
84
|
+
}
|
|
85
|
+
catch { /* keep non-empty directories */ }
|
|
86
|
+
}
|
|
87
|
+
if (removed.length > 0)
|
|
88
|
+
await writeTextSafely(manifestFile(paths), `${JSON.stringify({ skillFiles: manifest.skillFiles.filter((item) => !removed.includes(item)) }, null, 2)}\n`);
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
export async function removeAddedSkillFiles(destination, relativeFiles) {
|
|
92
|
+
for (const relative of relativeFiles)
|
|
93
|
+
await fs.rm(path.join(destination, relative), { force: true });
|
|
94
|
+
}
|