@kaydash9999/banker-plugins 0.20.5 → 0.22.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -0
- package/PRIVACY.md +25 -1
- package/README.md +63 -18
- package/bin/banker.js +173 -14
- package/bin/lib/codex-hooks.mjs +254 -0
- package/bin/lib/codex-routes.mjs +112 -0
- package/bin/lib/command-skill.cjs +19 -0
- package/bin/lib/telemetry-config.mjs +4 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +13 -10
- package/codex/skills/graceful-pause/SKILL.md +30 -0
- package/codex/skills/progress/SKILL.md +42 -0
- package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
- package/codex/skills/progress/scripts/progress.mjs +295 -0
- package/codex/transform-matrix.md +18 -13
- package/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/telemetry-count-codex.mjs +309 -0
- package/hooks/update-notify.mjs +105 -14
- package/package.json +1 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +15 -1
- package/skills/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +6 -0
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +14 -4
- package/skills/lineage/lineage.py +1582 -516
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +44 -10
- package/skills/obsidizer/obsidize.mjs +42 -11
- package/skills/omc-patch/SKILL.md +6 -0
- package/skills/payload-mon/SKILL.md +3 -1
- package/skills/play-qa/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +1 -1
- package/skills/remains/SKILL.md +1 -1
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +28 -10
- package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- package/skills/setup-omc-hud/SKILL.md +33 -3
- package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
- package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
- package/skills/smart-compact/SKILL.md +59 -13
- package/skills/smart-compact/scripts/codex-gate.mjs +385 -0
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +10 -6
- package/skills/ultra-interview/SKILL.md +13 -2
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/update-banker/SKILL.md +31 -6
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- package/skills/vertical-pptx/build_a4p.py +560 -359
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* setup-bypass-permissions 의 Codex 대상. Claude Code 의 bypassPermissions 에 해당하는 Codex 설정을 켜고 끈다:
|
|
4
|
+
* `$CODEX_HOME/config.toml` 최상위의 approval_policy = "never", sandbox_mode = "danger-full-access"
|
|
5
|
+
* (Codex 의 --dangerously-bypass-approvals-and-sandbox 와 같은 조합).
|
|
6
|
+
*
|
|
7
|
+
* - 켜기 전 두 키의 값(없으면 없음)을 config.toml.bypass-permissions.json 에 기록하고, 원본 바이트를
|
|
8
|
+
* config.toml.bypass-permissions.bak 에 둔다. off 는 기록대로 되돌린다. 기록이 없으면 이 스크립트가 쓰는 값과
|
|
9
|
+
* 같은 키만 뺀다.
|
|
10
|
+
* - 조직 정책(/etc/codex/requirements.toml 의 allowed_approval_policies, allowed_sandbox_modes)이 이 값을 허용하지
|
|
11
|
+
* 않으면 켜지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* 사용: node codex-bypass.mjs <on --yes|off|status>
|
|
14
|
+
* 종료: 0 완료 또는 이미 그 상태, 1 거부(파일은 그대로), 2 예기치 못한 오류, 3 이 계정이 쓸 수 없음(샌드박스 등)
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import { codexConfigFile, denied, eolOf, findKey, readToml, Refused, removeKey, setKey, tablesSetting, writeToml } from './codex-config.mjs';
|
|
21
|
+
|
|
22
|
+
const WANT = { approval_policy: '"never"', sandbox_mode: '"danger-full-access"' };
|
|
23
|
+
const KEYS = Object.keys(WANT);
|
|
24
|
+
const POLICY_FILES = ['/etc/codex/requirements.toml', '/etc/codex/managed_config.toml'];
|
|
25
|
+
|
|
26
|
+
const recordOf = (file) => `${file}.bypass-permissions.json`;
|
|
27
|
+
const backupOf = (file) => `${file}.bypass-permissions.bak`;
|
|
28
|
+
const valueOf = (text, key) => findKey(text, '', key)?.raw ?? null;
|
|
29
|
+
const isOn = (text) => KEYS.every((k) => valueOf(text, k) === WANT[k]);
|
|
30
|
+
|
|
31
|
+
// 조직 정책이 이 값을 막거나 정책 파일을 읽을 수 없으면 그 이유, 아니면 ''.
|
|
32
|
+
export function policyBlock(files = POLICY_FILES) {
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
let text = '';
|
|
35
|
+
try {
|
|
36
|
+
text = fs.readFileSync(f, 'utf8');
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (e.code === 'ENOENT') continue;
|
|
39
|
+
return `조직 정책 파일을 읽을 수 없음(${e.code}): ${f}`;
|
|
40
|
+
}
|
|
41
|
+
for (const [key, want] of [['allowed_approval_policies', 'never'], ['allowed_sandbox_modes', 'danger-full-access']]) {
|
|
42
|
+
const hit = findKey(text, '', key);
|
|
43
|
+
if (hit && !new RegExp(`["']${want}["']`).test(hit.raw)) return `${f} 의 ${key} 가 ${want} 를 허용하지 않음`;
|
|
44
|
+
}
|
|
45
|
+
// 옛 managed_config.toml 은 값 자체를 덮어쓴다. 다른 값이면 사용자 설정이 적용되지 않는다.
|
|
46
|
+
for (const k of KEYS) {
|
|
47
|
+
const forced = findKey(text, '', k)?.raw;
|
|
48
|
+
if (forced && forced.replace(/'/g, '"') !== WANT[k]) return `${f} 가 ${k} 를 ${forced} 로 정함`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 최상위 profile 이 고른 기본 프로필이 두 키를 따로 정하면 그 값이 이긴다. 그 프로필 이름, 없으면 ''.
|
|
55
|
+
export function overridingProfile(text) {
|
|
56
|
+
const raw = findKey(text, '', 'profile')?.raw ?? '';
|
|
57
|
+
const m = /^"([^"]+)"$|^'([^']+)'$/.exec(raw);
|
|
58
|
+
if (!m) return '';
|
|
59
|
+
const name = m[1] ?? m[2];
|
|
60
|
+
const table = `profiles."${name}"`;
|
|
61
|
+
// [profiles.<이름>] 표의 키, 또는 최상위의 점 키 profiles.<이름>.<키>.
|
|
62
|
+
const sets = KEYS.some((k) => findKey(text, table, k) || findKey(text, '', `${table}.${k}`));
|
|
63
|
+
return sets ? `profiles.${name}` : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function turnOn({ file = codexConfigFile(), policies = POLICY_FILES } = {}) {
|
|
67
|
+
const why = policyBlock(policies);
|
|
68
|
+
if (why) return { code: 1, msg: `켜지 않음: ${why}` };
|
|
69
|
+
const { text, mode, exists } = readToml(file);
|
|
70
|
+
const prof = overridingProfile(text);
|
|
71
|
+
if (prof) return { code: 1, msg: `켜지 않음: 기본 프로필 ${prof} 가 approval_policy 나 sandbox_mode 를 정해 최상위 값이 적용되지 않음. 그 프로필의 값을 직접 바꾸십시오` };
|
|
72
|
+
if (isOn(text)) return { code: 0, msg: '이미 켜져 있음' };
|
|
73
|
+
const before = Object.fromEntries(KEYS.map((k) => [k, valueOf(text, k)]));
|
|
74
|
+
let next = text;
|
|
75
|
+
for (const k of KEYS) next = setKey(next, '', k, WANT[k]);
|
|
76
|
+
if (!fs.existsSync(path.dirname(file))) return { code: 1, msg: `Codex 설정 폴더가 없음: ${path.dirname(file)}` };
|
|
77
|
+
// 켜져 있지 않은 상태에서 켜므로 지금 값이 되돌릴 값이다. 예전 기록이 남아 있어도(켬, 손으로 바꿈, 다시 켬) 지금 값으로 바꾼다.
|
|
78
|
+
// 설정을 먼저 쓰고 기록을 쓴다. 설정 쓰기가 실패하면 앞선 기록이 그대로 남는다.
|
|
79
|
+
const nl = eolOf(text);
|
|
80
|
+
writeToml(file, next.endsWith(nl) || !next ? next : `${next}${nl}`, { mode, backup: exists ? backupOf(file) : null });
|
|
81
|
+
writeToml(recordOf(file), `${JSON.stringify({ before, existed: exists })}\n`);
|
|
82
|
+
return { code: 0, msg: '켬: approval_policy = "never", sandbox_mode = "danger-full-access"' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function turnOff({ file = codexConfigFile() } = {}) {
|
|
86
|
+
const { text, mode, exists } = readToml(file);
|
|
87
|
+
let record = null;
|
|
88
|
+
try { record = JSON.parse(fs.readFileSync(recordOf(file), 'utf8')); } catch { /* 기록 없음 */ }
|
|
89
|
+
let next = text;
|
|
90
|
+
// 기록이 없으면 두 키가 모두 이 스크립트의 값일 때만 뺀다. 사용자가 스스로 둔 approval_policy = "never" 하나는 남긴다.
|
|
91
|
+
const ours = record || isOn(text);
|
|
92
|
+
for (const k of KEYS) {
|
|
93
|
+
const was = record?.before?.[k];
|
|
94
|
+
if (typeof was === 'string') next = setKey(next, '', k, was);
|
|
95
|
+
else if (ours) next = removeKey(next, '', k);
|
|
96
|
+
}
|
|
97
|
+
if (next !== text && exists) writeToml(file, next, { mode });
|
|
98
|
+
fs.rmSync(recordOf(file), { force: true });
|
|
99
|
+
return { code: 0, msg: next === text ? '이미 꺼져 있음' : record ? '끔: 켜기 전 값으로 되돌림' : '끔: 이 스크립트가 쓰는 두 값의 키를 뺌' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function status({ file = codexConfigFile(), policies = POLICY_FILES } = {}) {
|
|
103
|
+
const { text } = readToml(file);
|
|
104
|
+
const out = [`${isOn(text) ? '켜짐' : '꺼짐'}: approval_policy = ${valueOf(text, 'approval_policy') ?? '(없음)'}, sandbox_mode = ${valueOf(text, 'sandbox_mode') ?? '(없음)'}`];
|
|
105
|
+
const prof = overridingProfile(text);
|
|
106
|
+
if (prof) out.push(`기본 프로필 ${prof} 가 값을 정해 최상위 값이 적용되지 않음`);
|
|
107
|
+
const profiles = [...new Set(KEYS.flatMap((k) => tablesSetting(text, 'profiles', k)))];
|
|
108
|
+
if (profiles.length) out.push(`프로필이 따로 정함(그 프로필로 시작하면 그 값): ${profiles.join(', ')}`);
|
|
109
|
+
const why = policyBlock(policies);
|
|
110
|
+
if (why) out.push(`조직 정책: ${why}`);
|
|
111
|
+
if (fs.existsSync(recordOf(file))) out.push(`되돌리기 기록: ${recordOf(file)}`);
|
|
112
|
+
return { code: 0, msg: out.join('\n') };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function run(argv) {
|
|
116
|
+
const [cmd] = argv;
|
|
117
|
+
if (cmd === 'on' && !argv.includes('--yes')) return { code: 1, msg: '켜기는 확인을 받은 뒤 --yes 와 함께 실행' };
|
|
118
|
+
if (cmd === 'on') return turnOn();
|
|
119
|
+
if (cmd === 'off') return turnOff();
|
|
120
|
+
return status();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function main(argv) {
|
|
124
|
+
let r;
|
|
125
|
+
try {
|
|
126
|
+
r = run(argv);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
r = e instanceof Refused ? { code: 1, msg: `거부: ${e.message}` } : denied(e) ? { code: 3, msg: `쓸 수 없음(${e.code}): ${e.path ?? ''}${e.code === 'EPERM' && process.platform === 'win32' ? '. 다른 프로그램(Codex, 편집기)이 파일을 잡고 있을 수 있음' : ''}` } : { code: 2, msg: `오류: ${e.message}` };
|
|
129
|
+
}
|
|
130
|
+
console.log(`${r.msg}\n설정 파일: ${codexConfigFile()}`);
|
|
131
|
+
return r.code;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const same = (a, b) => { try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return false; } };
|
|
135
|
+
if (process.argv[1] && same(fileURLToPath(import.meta.url), path.resolve(process.argv[1]))) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Codex 의 config.toml 을 줄 단위로 읽고 고치는 공통 모듈. setup-bypass-permissions 가 정본이고 setup-omc-hud 의
|
|
3
|
+
* assets/ 에 같은 사본을 둔다(scripts/sync-adapter.js 가 지킴).
|
|
4
|
+
*
|
|
5
|
+
* TOML 전체를 다시 쓰지 않는다. 바꾸는 키의 줄만 고쳐 주석, 순서, 다른 표는 그대로 둔다.
|
|
6
|
+
* - 줄을 훑으며 문자열(한 줄, 여러 줄)과 괄호 깊이를 따라가, 여러 줄 문자열이나 배열 안의 줄을 표 머리나 키로 보지 않는다.
|
|
7
|
+
* - 키 이름은 따옴표를 벗겨 비교한다(`"approval_policy"` 도 approval_policy). 값 끝의 주석은 값으로 보지 않는다.
|
|
8
|
+
* - 최상위가 표를 점 키나 인라인 표로 정했는지(definedInline) 알려, 그런 파일에 같은 표를 더하지 않게 한다.
|
|
9
|
+
* - 파일이 링크이거나 일반 파일이 아니면 고치지 않는다. 쓰기 전 원본을 백업(이 계정만 읽음)하고, 새 파일에 쓴 뒤
|
|
10
|
+
* 바꿔 넣으며 파일 모드를 지킨다.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
// Codex 처럼 상대 경로 CODEX_HOME 은 지금 폴더 기준으로 푼다.
|
|
17
|
+
export function codexConfigFile(env = process.env) {
|
|
18
|
+
const home = env.CODEX_HOME ? path.resolve(env.CODEX_HOME) : path.join(os.homedir(), '.codex');
|
|
19
|
+
return path.join(home, 'config.toml');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Refused extends Error {}
|
|
23
|
+
|
|
24
|
+
// { text, mode, exists }. 없으면 빈 글. 링크나 일반 파일이 아니면 Refused.
|
|
25
|
+
export function readToml(file) {
|
|
26
|
+
let st;
|
|
27
|
+
try {
|
|
28
|
+
st = fs.lstatSync(file);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (e.code === 'ENOENT') return { text: '', mode: null, exists: false };
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
if (!st.isFile()) throw new Refused(`${file} 이 일반 파일이 아님(링크이거나 다른 종류)`);
|
|
34
|
+
return { text: fs.readFileSync(file, 'utf8'), mode: st.mode & 0o7777, exists: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다.
|
|
38
|
+
export function writeToml(file, text, { mode = null, backup = null } = {}) {
|
|
39
|
+
if (backup && fs.existsSync(file)) {
|
|
40
|
+
fs.copyFileSync(file, backup);
|
|
41
|
+
fs.chmodSync(backup, 0o600); // 원본에 비밀 값이 있을 수 있다
|
|
42
|
+
}
|
|
43
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
fs.writeFileSync(tmp, text, { flag: 'wx', mode: mode ?? 0o600 });
|
|
46
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
47
|
+
fs.renameSync(tmp, file);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
fs.rmSync(tmp, { force: true });
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lines = (text) => text.split(/\r?\n/);
|
|
55
|
+
const eol = (text) => (text.includes('\r\n') ? '\r\n' : '\n');
|
|
56
|
+
// 파일이 쓰는 줄바꿈(CRLF 가 하나라도 있으면 CRLF).
|
|
57
|
+
export const eolOf = eol;
|
|
58
|
+
const TRIPLE = ['"""', "'''"];
|
|
59
|
+
|
|
60
|
+
// 키나 표 이름을 낱말 배열로. 따옴표 안의 점은 나누지 않는다(`profiles."a.b"` 는 ['profiles', 'a.b']).
|
|
61
|
+
export const nameParts = (s) => (s.match(/"[^"]*"|'[^']*'|[^.\s]+/g) ?? []).map((p) => p.replace(/^"(.*)"$|^'(.*)'$/, '$1$2'));
|
|
62
|
+
// 비교용 이름. 낱말 사이를 \u0000 으로 이어, 점이 든 낱말과 점으로 나뉜 낱말을 구분한다.
|
|
63
|
+
const plainName = (s) => nameParts(s).join('\u0000');
|
|
64
|
+
|
|
65
|
+
// 한 줄을 문자열 밖에서 훑는다. { d: 괄호 깊이 변화, multi: 줄 끝에 열려 있는 여러 줄 문자열 구분자, cut: 주석 시작 위치 }.
|
|
66
|
+
function scanLine(s, multi = '') {
|
|
67
|
+
let d = 0;
|
|
68
|
+
for (let i = 0; i < s.length; i++) {
|
|
69
|
+
if (multi) {
|
|
70
|
+
if (s.startsWith(multi, i)) { i += 2; multi = ''; } else if (multi === TRIPLE[0] && s[i] === '\\') i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const c = s[i];
|
|
74
|
+
if (TRIPLE.some((t) => s.startsWith(t, i))) { multi = s.slice(i, i + 3); i += 2; }
|
|
75
|
+
else if (c === '"') { for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === '\\') i++; }
|
|
76
|
+
else if (c === "'") { const k = s.indexOf("'", i + 1); i = k < 0 ? s.length : k; }
|
|
77
|
+
else if (c === '#') return { d, multi, cut: i };
|
|
78
|
+
else if (c === '[' || c === '{') d++;
|
|
79
|
+
else if (c === ']' || c === '}') d--;
|
|
80
|
+
}
|
|
81
|
+
return { d, multi, cut: s.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 표 머리. 이름 안의 따옴표 친 낱말에는 ] 가 들어갈 수 있다(`[projects."/a]b"]`).
|
|
85
|
+
// 이름 부분은 탐욕 한 번으로 읽는다(겹치는 공백 패턴은 긴 줄에서 역추적이 폭증한다). 앞뒤 공백은 nameParts 가 지운다.
|
|
86
|
+
const HEADER_RE = /^\s*\[\[?((?:"[^"]*"|'[^']*'|[^\]"'])+)\]\]?\s*(#.*)?$/;
|
|
87
|
+
const KEY_RE = /^\s*((?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+))*)\s*=(.*)$/;
|
|
88
|
+
|
|
89
|
+
// 줄마다 { kind: 'header'|'key'|'inside'|'other', name, end }. header 의 name 은 표 이름, key 의 name 은 키 이름이고
|
|
90
|
+
// end 는 값이 끝나는 줄 다음. 여러 줄 문자열이나 배열 안의 줄은 inside.
|
|
91
|
+
export function scan(text) {
|
|
92
|
+
const ls = lines(text);
|
|
93
|
+
const out = ls.map(() => ({ kind: 'other' }));
|
|
94
|
+
for (let i = 0; i < ls.length; i++) {
|
|
95
|
+
const header = HEADER_RE.exec(ls[i]);
|
|
96
|
+
if (header) { out[i] = { kind: 'header', name: plainName(header[1]) }; continue; }
|
|
97
|
+
const key = KEY_RE.exec(ls[i]);
|
|
98
|
+
if (!key) continue;
|
|
99
|
+
let { d, multi } = scanLine(key[2]);
|
|
100
|
+
let j = i + 1;
|
|
101
|
+
for (; (d > 0 || multi) && j < ls.length; j++) {
|
|
102
|
+
out[j] = { kind: 'inside' };
|
|
103
|
+
const r = scanLine(ls[j], multi);
|
|
104
|
+
d += r.d;
|
|
105
|
+
multi = r.multi;
|
|
106
|
+
}
|
|
107
|
+
out[i] = { kind: 'key', name: plainName(key[1]), end: j };
|
|
108
|
+
i = j - 1;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 표의 줄 범위 [from, to). table '' 은 최상위. 표가 없으면 null.
|
|
114
|
+
function tableRange(info, table) {
|
|
115
|
+
table = table.includes('\u0000') || !table ? table : plainName(table);
|
|
116
|
+
let from = table ? -1 : 0;
|
|
117
|
+
for (let i = 0; i < info.length; i++) {
|
|
118
|
+
if (info[i].kind !== 'header') continue;
|
|
119
|
+
if (from >= 0) return [from, i];
|
|
120
|
+
if (info[i].name === table) from = i + 1;
|
|
121
|
+
}
|
|
122
|
+
return from >= 0 ? [from, info.length] : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function keysOf(text, table) {
|
|
126
|
+
const info = scan(text);
|
|
127
|
+
const range = tableRange(info, table);
|
|
128
|
+
if (!range) return [];
|
|
129
|
+
const keys = [];
|
|
130
|
+
for (let i = range[0]; i < range[1]; i++) if (info[i].kind === 'key') keys.push({ at: i, end: info[i].end, name: info[i].name });
|
|
131
|
+
return keys;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 키의 { at, end, raw }. raw 는 값 끝의 주석을 뺀 글. 없으면 null.
|
|
135
|
+
export function findKey(text, table, key) {
|
|
136
|
+
const want = nameParts(key).join('\u0000');
|
|
137
|
+
const hit = keysOf(text, plainName(table)).find((k) => k.name === want);
|
|
138
|
+
if (!hit) return null;
|
|
139
|
+
const ls = lines(text).slice(hit.at, hit.end);
|
|
140
|
+
const parts = [ls[0].slice(ls[0].indexOf('=') + 1), ...ls.slice(1)];
|
|
141
|
+
let multi = '';
|
|
142
|
+
for (const part of parts.slice(0, -1)) multi = scanLine(part, multi).multi;
|
|
143
|
+
const last = parts.length - 1;
|
|
144
|
+
parts[last] = parts[last].slice(0, scanLine(parts[last], multi).cut); // 마지막 줄의 주석을 뗀다
|
|
145
|
+
const raw = parts.join('\n');
|
|
146
|
+
return { at: hit.at, end: hit.end, raw: raw.trim() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 최상위가 이 표를 점 키나 인라인 표로 정했는지(`tui.x = 1`, `tui = { ... }`). 그런 파일에 [표] 를 더하면 키가 겹쳐
|
|
150
|
+
// Codex 가 설정을 읽지 못한다.
|
|
151
|
+
export function definedInline(text, table) {
|
|
152
|
+
const t = plainName(table);
|
|
153
|
+
return keysOf(text, '').some((k) => k.name === t || k.name.startsWith(`${t}\u0000`));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 키를 rawValue 로 둔다. 있으면 그 줄을 바꾸고, 없으면 표 끝(최상위는 첫 표 머리 앞)에 넣는다. 표가 없으면 끝에 만든다.
|
|
157
|
+
// comment 가 있으면 키 바로 위에 그 주석 줄을 둔다.
|
|
158
|
+
export function setKey(text, table, key, rawValue, comment = '') {
|
|
159
|
+
const ls = lines(text);
|
|
160
|
+
const nl = eol(text);
|
|
161
|
+
const line = [...(comment ? [comment] : []), `${key} = ${rawValue}`];
|
|
162
|
+
const hit = findKey(text, table, key);
|
|
163
|
+
if (hit) {
|
|
164
|
+
const above = comment && ls[hit.at - 1] === comment ? 1 : 0;
|
|
165
|
+
ls.splice(hit.at - above, hit.end - hit.at + above, ...line);
|
|
166
|
+
return ls.join(nl);
|
|
167
|
+
}
|
|
168
|
+
const range = tableRange(scan(text), table);
|
|
169
|
+
if (!range) {
|
|
170
|
+
while (ls.length && ls[ls.length - 1] === '') ls.pop();
|
|
171
|
+
return [...ls, ...(ls.length ? [''] : []), `[${table}]`, ...line, ''].join(nl);
|
|
172
|
+
}
|
|
173
|
+
// 다음 표 머리에 붙은 주석(`# MCP servers` 처럼 머리 바로 위의 주석 줄)과 그 앞 빈 줄은 건너뛰고 넣는다.
|
|
174
|
+
let at = range[1];
|
|
175
|
+
if (range[1] < ls.length) while (at > range[0] && ls[at - 1].trim().startsWith('#')) at--;
|
|
176
|
+
while (at > range[0] && ls[at - 1].trim() === '') at--;
|
|
177
|
+
ls.splice(at, 0, ...line);
|
|
178
|
+
return ls.join(nl);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 키를 뺀다. comment 와 같은 주석 줄이 바로 위에 있으면 그것도 뺀다.
|
|
182
|
+
export function removeKey(text, table, key, comment = '') {
|
|
183
|
+
const hit = findKey(text, table, key);
|
|
184
|
+
if (!hit) return text;
|
|
185
|
+
const ls = lines(text);
|
|
186
|
+
const above = comment && ls[hit.at - 1] === comment ? 1 : 0;
|
|
187
|
+
ls.splice(hit.at - above, hit.end - hit.at + above);
|
|
188
|
+
return ls.join(eol(text));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 표에 키도 주석도 없으면 그 머리와 뒤의 빈 줄을 뺀다.
|
|
192
|
+
export function dropEmptyTable(text, table) {
|
|
193
|
+
const info = scan(text);
|
|
194
|
+
const range = tableRange(info, table);
|
|
195
|
+
if (!range) return text;
|
|
196
|
+
const ls = lines(text);
|
|
197
|
+
if (ls.slice(range[0], range[1]).some((l) => l.trim())) return text;
|
|
198
|
+
ls.splice(range[0] - 1, range[1] - range[0] + 1);
|
|
199
|
+
return ls.join(eol(text));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 이름이 prefix 로 시작하는 표들 중 key 를 정한 표 이름(예: profiles.* 의 approval_policy).
|
|
203
|
+
export function tablesSetting(text, prefix, key) {
|
|
204
|
+
const p = plainName(prefix);
|
|
205
|
+
const names = scan(text).filter((x) => x.kind === 'header' && x.name.startsWith(`${p}\u0000`)).map((x) => x.name);
|
|
206
|
+
return [...new Set(names)].filter((n) => findKey(text, n, key)).map((n) => n.split('\u0000').join('.'));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 쓰기 실패가 이 계정의 권한(샌드박스, 읽기 전용, 다른 소유자) 때문인지.
|
|
210
|
+
export const denied = (e) => ['EACCES', 'EPERM', 'EROFS'].includes(e?.code);
|
|
@@ -21,6 +21,7 @@ Exit status: 0 done, or nothing to do. 1 refused. 2 unexpected error. 3 this acc
|
|
|
21
21
|
the file or its folder. Every result ends with the line `settings file: <path>` on stdout.
|
|
22
22
|
Python 3.6 or later.
|
|
23
23
|
"""
|
|
24
|
+
|
|
24
25
|
import errno
|
|
25
26
|
import json
|
|
26
27
|
import os
|
|
@@ -43,7 +44,9 @@ def settings_path():
|
|
|
43
44
|
|
|
44
45
|
def policy_home():
|
|
45
46
|
# The override exists for this skill's tests alone.
|
|
46
|
-
if os.environ.get("BANKER_BYPASS_TEST") == "1" and os.environ.get(
|
|
47
|
+
if os.environ.get("BANKER_BYPASS_TEST") == "1" and os.environ.get(
|
|
48
|
+
"BANKER_BYPASS_POLICY_HOME"
|
|
49
|
+
):
|
|
47
50
|
return os.environ["BANKER_BYPASS_POLICY_HOME"]
|
|
48
51
|
return POLICY_HOMES.get(sys.platform, "/etc/claude-code")
|
|
49
52
|
|
|
@@ -59,7 +62,11 @@ ABSENT = (errno.ENOENT, errno.ENOTDIR, errno.EISDIR)
|
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def unreadable_policy(path):
|
|
62
|
-
return Refused(
|
|
65
|
+
return Refused(
|
|
66
|
+
"this machine's managed policy cannot be read ("
|
|
67
|
+
+ path
|
|
68
|
+
+ "); ask the administrator whether it allows the mode"
|
|
69
|
+
)
|
|
63
70
|
|
|
64
71
|
|
|
65
72
|
def policy_files():
|
|
@@ -73,7 +80,12 @@ def policy_files():
|
|
|
73
80
|
if e.errno not in ABSENT:
|
|
74
81
|
raise unreadable_policy(drop)
|
|
75
82
|
names = []
|
|
76
|
-
files += [
|
|
83
|
+
files += [
|
|
84
|
+
os.path.join(drop, n)
|
|
85
|
+
for n in sorted(
|
|
86
|
+
n for n in names if n.endswith(".json") and not n.startswith(".")
|
|
87
|
+
)
|
|
88
|
+
]
|
|
77
89
|
return files
|
|
78
90
|
|
|
79
91
|
|
|
@@ -108,6 +120,7 @@ def load(path):
|
|
|
108
120
|
raise Refused("settings.json is not valid UTF-8")
|
|
109
121
|
if not text.strip():
|
|
110
122
|
return {}, raw
|
|
123
|
+
|
|
111
124
|
def reject(token):
|
|
112
125
|
raise ValueError("the constant " + token + " is not JSON")
|
|
113
126
|
|
|
@@ -134,7 +147,9 @@ def replace(path, data, mode):
|
|
|
134
147
|
except OSError as e:
|
|
135
148
|
if e.errno not in (errno.EBUSY, errno.EXDEV):
|
|
136
149
|
raise
|
|
137
|
-
with open(
|
|
150
|
+
with open(
|
|
151
|
+
path, "wb"
|
|
152
|
+
) as target: # a bind-mounted single file refuses the rename: write in place
|
|
138
153
|
target.write(data)
|
|
139
154
|
os.remove(tmp)
|
|
140
155
|
except BaseException:
|
|
@@ -148,21 +163,34 @@ def save(path, data, raw):
|
|
|
148
163
|
real = os.path.realpath(path)
|
|
149
164
|
mode = os.stat(real).st_mode & 0o777 if os.path.exists(real) else 0o600
|
|
150
165
|
bom = BOM if raw is not None and raw.startswith(BOM) else b""
|
|
151
|
-
replace(
|
|
166
|
+
replace(
|
|
167
|
+
real,
|
|
168
|
+
bom + (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"),
|
|
169
|
+
mode,
|
|
170
|
+
)
|
|
152
171
|
|
|
153
172
|
|
|
154
173
|
def check_on(path, data):
|
|
155
174
|
# Claude Code lets root use the mode inside a sandbox it recognises: IS_SANDBOX exactly "1", or
|
|
156
175
|
# CLAUDE_CODE_BUBBLEWRAP set to a true value (2.1.289 reads 1, true, yes, on; 0 or false is not one).
|
|
157
|
-
bubblewrap = os.environ.get("CLAUDE_CODE_BUBBLEWRAP", "").strip().lower() in (
|
|
176
|
+
bubblewrap = os.environ.get("CLAUDE_CODE_BUBBLEWRAP", "").strip().lower() in (
|
|
177
|
+
"1",
|
|
178
|
+
"true",
|
|
179
|
+
"yes",
|
|
180
|
+
"on",
|
|
181
|
+
)
|
|
158
182
|
sandboxed = os.environ.get("IS_SANDBOX") == "1" or bubblewrap
|
|
159
183
|
if hasattr(os, "geteuid") and os.geteuid() == 0 and not sandboxed:
|
|
160
|
-
raise Refused(
|
|
184
|
+
raise Refused(
|
|
185
|
+
"Claude Code will not start in bypassPermissions as root; IS_SANDBOX=1 marks a real sandbox"
|
|
186
|
+
)
|
|
161
187
|
blocked = policy_block()
|
|
162
188
|
if blocked:
|
|
163
189
|
raise Refused("managed policy forbids bypassPermissions: " + blocked)
|
|
164
190
|
if forbids(data):
|
|
165
|
-
raise Refused(
|
|
191
|
+
raise Refused(
|
|
192
|
+
"settings.json itself sets permissions.disableBypassPermissionsMode"
|
|
193
|
+
)
|
|
166
194
|
|
|
167
195
|
|
|
168
196
|
def forget_record(path):
|
|
@@ -176,7 +204,9 @@ def forget_record(path):
|
|
|
176
204
|
|
|
177
205
|
def turn_on(path):
|
|
178
206
|
if not os.path.isdir(os.path.dirname(path)):
|
|
179
|
-
raise Refused(
|
|
207
|
+
raise Refused(
|
|
208
|
+
"there is no Claude Code config folder at " + os.path.dirname(path)
|
|
209
|
+
)
|
|
180
210
|
data, raw = load(path)
|
|
181
211
|
check_on(path, data)
|
|
182
212
|
if data.get("permissions", {}).get("defaultMode") == MODE:
|
|
@@ -197,13 +227,21 @@ def turn_off(path):
|
|
|
197
227
|
data, raw = load(path)
|
|
198
228
|
current = data.get("permissions", {}).get("defaultMode")
|
|
199
229
|
if current != MODE:
|
|
200
|
-
print(
|
|
230
|
+
print(
|
|
231
|
+
"defaultMode = "
|
|
232
|
+
+ str(current)
|
|
233
|
+
+ " (not bypassPermissions, nothing changed)"
|
|
234
|
+
)
|
|
201
235
|
return 0
|
|
202
|
-
if not os.access(
|
|
236
|
+
if not os.access(
|
|
237
|
+
path, os.W_OK
|
|
238
|
+
): # a read-only settings file is a lock: off honours it as on does
|
|
203
239
|
raise PermissionError(errno.EACCES, "this account may not write", path)
|
|
204
240
|
del data["permissions"]["defaultMode"]
|
|
205
241
|
save(path, data, raw)
|
|
206
|
-
forget_record(
|
|
242
|
+
forget_record(
|
|
243
|
+
path
|
|
244
|
+
) # only after the write: if it failed, the record is still the way back
|
|
207
245
|
print("defaultMode removed (Claude Code's default start mode)")
|
|
208
246
|
return 0
|
|
209
247
|
|
|
@@ -212,7 +250,10 @@ def outcome(argv, path):
|
|
|
212
250
|
actions = {("on", "--yes"): turn_on, ("off",): turn_off}
|
|
213
251
|
action = actions.get(tuple(argv[1:]))
|
|
214
252
|
if action is None:
|
|
215
|
-
print(
|
|
253
|
+
print(
|
|
254
|
+
"refused: usage is `bypass-permissions.py on --yes` or `bypass-permissions.py off`",
|
|
255
|
+
file=sys.stderr,
|
|
256
|
+
)
|
|
216
257
|
return 1
|
|
217
258
|
try:
|
|
218
259
|
return action(path)
|
|
@@ -221,12 +262,22 @@ def outcome(argv, path):
|
|
|
221
262
|
return 1
|
|
222
263
|
except OSError as e:
|
|
223
264
|
if e.errno in (errno.EACCES, errno.EPERM, errno.EROFS):
|
|
224
|
-
print(
|
|
265
|
+
print(
|
|
266
|
+
"write blocked: " + str(e) + "; settings.json left as it was",
|
|
267
|
+
file=sys.stderr,
|
|
268
|
+
)
|
|
225
269
|
return 3
|
|
226
270
|
print("unexpected error: " + str(e) + "; check settings.json", file=sys.stderr)
|
|
227
271
|
return 2
|
|
228
272
|
except Exception as e: # anything else stopped it partway too
|
|
229
|
-
print(
|
|
273
|
+
print(
|
|
274
|
+
"unexpected error: "
|
|
275
|
+
+ type(e).__name__
|
|
276
|
+
+ ": "
|
|
277
|
+
+ str(e)
|
|
278
|
+
+ "; check settings.json",
|
|
279
|
+
file=sys.stderr,
|
|
280
|
+
)
|
|
230
281
|
return 2
|
|
231
282
|
|
|
232
283
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup-omc-hud
|
|
3
|
-
description: "Claude Code 상태표시줄 omc_hud 래퍼를 스킬에 든 파일로 설치. 내려받지 않고 오프라인에서 모든 OS 같은 명령으로 동작하며, PL 칸에 쓰는 payload-mon 모듈도 켬. Codex 는
|
|
3
|
+
description: "Claude Code 상태표시줄 omc_hud 래퍼를 스킬에 든 파일로 설치. 내려받지 않고 오프라인에서 모든 OS 같은 명령으로 동작하며, PL 칸에 쓰는 payload-mon 모듈도 켬. Codex 는 TUI 상태표시줄(`[tui] status_line`)을 OMX 프리셋으로 켜고 끔. 'setup-omc-hud'/'omc_hud 설치'/'상태표시줄 설치' 또는 `/banker:setup` 시 사용."
|
|
4
4
|
argument-hint: "[off|status]"
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -12,9 +12,39 @@ argument-hint: "[off|status]"
|
|
|
12
12
|
- Windows, Linux, macOS 모두 `node` 명령 하나로 같은 절차를 밟는다.
|
|
13
13
|
- 설치 파일: `apply-hud.mjs`(설치 스크립트), `omc-hud-custom.mjs`(래퍼 v4), `hud-config.json`(HUD 설정), `LICENSE-omc_hud`(원본 omc_hud 의 MIT 라이선스)
|
|
14
14
|
|
|
15
|
-
**런타임:** Claude Code 상태표시줄(`<설정 폴더>/settings.json` 의 `statusLine`)이 대상이다.
|
|
15
|
+
**런타임:** Claude Code 상태표시줄(`<설정 폴더>/settings.json` 의 `statusLine`)이 대상이다.
|
|
16
|
+
Codex 에서 실행했고 요청이 대상을 말하지 않으면, 설치 전에 어느 쪽인지 묻는다(질문 도구가 없으면 번호 선택지를 평문으로). 인자가 `off`, `status` 여도 같다.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
| 대상 | 할 일 |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Codex 자신의 상태표시줄 | 아래 "Codex 상태표시줄" 절을 한다 |
|
|
21
|
+
| 같은 머신의 Claude Code 상태표시줄 | 아래 0~3단계를 그대로 한다 |
|
|
22
|
+
|
|
23
|
+
- 물을 수 없는 실행(`codex exec` 등)이고 대상이 없으면 아무것도 설치하지 않고 두 선택지를 알린다. 시험한 두 모델이 대상 없는 요청에 서로 다른 쪽을 설치했다.
|
|
24
|
+
- Claude Code 쪽 설치는 `~/.claude` 에 쓴다. Codex 의 `workspace-write` 샌드박스가 이 쓰기를 막으므로, `apply-hud.mjs` 와 `payload-mon on` 은 샌드박스 밖 실행으로 승인을 요청해 돌린다. 승인한 실행은 Codex 0.144.5 에서 확인했다. 승인을 요청할 수 없거나(승인 정책 `never`) 거부되면, 같은 명령을 Codex 입력창에서 `!` 를 붙여 사용자가 직접 실행하게 안내한다(예: `!node "<이 스킬 디렉터리 절대경로>/assets/apply-hud.mjs"`). 사용자가 친 `!` 명령은 샌드박스 밖에서 돈다(Codex 0.144.5, `-a never` 에서 실측). `codex exec` 처럼 사람이 없는 실행이면 그 명령을 보고하고 멈춘다. `--add-dir ~/.claude` 처럼 설정 폴더를 통째로 여는 방법은 쓰지 않는다. 그 세션이 훅과 설정을 고칠 수 있게 되어 샌드박스가 무력해진다.
|
|
25
|
+
- OMX 의 `omx hud --tmux` 는 지금 tmux 창을 나눠 HUD 칸을 새로 띄운다. 사용자가 그 칸을 원한다고 했을 때만 실행한다.
|
|
26
|
+
|
|
27
|
+
**인자가 `off` 또는 `status` 일 때:** 대상이 Claude Code 면 0~3단계를 건너뛰고 "인자 off, status" 절만 실행한다. 대상이 Codex 면 아래 "Codex 상태표시줄" 절의 `off`, `status` 만 실행하고 그 밖의 절(`claude-update-last` 포함)은 하지 않는다.
|
|
28
|
+
|
|
29
|
+
### Codex 상태표시줄
|
|
30
|
+
|
|
31
|
+
Codex TUI 의 상태표시줄은 `$CODEX_HOME/config.toml`(기본 `~/.codex/config.toml`)의 `[tui]` `status_line` 이다. OMX 는 `omx setup` 에서 이 줄을 `# omx:managed-status-line` 표시와 함께 관리한다.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node "<이 스킬 디렉터리 절대경로>/assets/codex-status-line.mjs" on [--preset focused|minimal]
|
|
35
|
+
node "<이 스킬 디렉터리 절대경로>/assets/codex-status-line.mjs" off
|
|
36
|
+
node "<이 스킬 디렉터리 절대경로>/assets/codex-status-line.mjs" status
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `on` 은 `status_line` 이 없을 때만 OMX 프리셋 항목(기본 `focused`: 모델과 추론 강도, git 브랜치, 남은 컨텍스트, 입력과 출력 토큰, 5시간과 주간 한도)으로 넣고 `# banker:status-line` 표시를 붙인다. OMX 나 사용자가 둔 줄은 바꾸지 않고 누가 둔 것인지 알린다.
|
|
40
|
+
- `off` 는 banker 표시가 붙은 줄만 뺀다. 쓰기 전 원본은 `config.toml.banker-hud.bak` 에 있다.
|
|
41
|
+
- 종료 코드: 0 완료 또는 이미 그 상태, 1 거부(설정 파일이 링크이거나 일반 파일이 아님, 설정 폴더 없음, 모르는 프리셋), 2 예기치 못한 오류, 3 이 계정이 쓸 수 없음.
|
|
42
|
+
- `~/.codex` 쓰기라 Codex 의 `workspace-write` 샌드박스에서는 종료 3 이 된다. 그 명령만 샌드박스 밖 실행 승인을 요청한다. 승인을 요청할 수 없거나 거부되면 사용자가 입력창에서 `!` 를 붙여 직접 실행하게 안내한다. `--add-dir ~/.codex` 는 쓰지 않는다.
|
|
43
|
+
- 결과를 보고하고 Codex 를 다시 열어야 보인다고 알린다. 이 경로에서는 위 0~3단계를 하지 않는다.
|
|
44
|
+
- OMX 의 별도 HUD 칸(`omx hud --tmux`)은 지금 tmux 창을 나눈다. 사용자가 그 칸을 원한다고 했을 때만 실행한다.
|
|
45
|
+
- `omx setup` 은 `[tui]` 를 다시 쓰며 주석을 버릴 수 있다. 그러면 banker 표시가 사라져 그 줄은 사용자의 줄로 보이고, `off` 는 건드리지 않는다. `omx setup --force` 는 OMX 프리셋으로 바꿔 넣는다. 둘 다 설정은 깨지지 않는다.
|
|
46
|
+
- Codex 의 `/statusline` 으로 항목을 바꾸면, 표시가 사라졌거나 값이 프리셋과 다를 때 `off` 는 그 줄을 사용자의 것으로 보고 남긴다.
|
|
47
|
+
- `off` 뒤 `[tui]` 표에 키도 주석도 남지 않으면 그 머리도 뺀다.
|
|
18
48
|
|
|
19
49
|
## 표시 형식
|
|
20
50
|
|