deel-local-cli 1.16.0 → 1.17.2

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.
@@ -12,6 +12,7 @@ import {
12
12
  assistantMessage, toolMessage,
13
13
  } from './adapter.js';
14
14
  import { 벤더 } from './toolfit.js';
15
+ import { 세션이름짓기 } from './wire.js';
15
16
  import { 말 } from '../i18n/index.js';
16
17
 
17
18
  const READ_TOOL = {
@@ -96,6 +97,19 @@ const SKIPPED = [
96
97
  ['ctx', '컨텍스트 길이'],
97
98
  ];
98
99
 
100
+ /**
101
+ * 이 진단 한 번을 가리키는 번호.
102
+ *
103
+ * agent/store.js 의 대화 번호와 **같은 모양**으로 짓는다(YYYYMMDD-HHMMSS).
104
+ * 모양이 다르면 세션이름짓기 가 안전한 꼴이 아니라고 보고 지문으로 바꾸는데,
105
+ * 지문은 사람이 대시보드에서 「아까 그 진단」 을 못 찾게 만든다.
106
+ */
107
+ function 진단번호(at = new Date()) {
108
+ const 두 = (n) => String(n).padStart(2, '0');
109
+ return `${at.getFullYear()}${두(at.getMonth() + 1)}${두(at.getDate())}`
110
+ + `-${두(at.getHours())}${두(at.getMinutes())}${두(at.getSeconds())}`;
111
+ }
112
+
99
113
  export async function probe(conn, onStep = () => {}) {
100
114
  const { kind: shape, base, auth, model } = conn;
101
115
  const key = conn.key ?? '';
@@ -106,7 +120,19 @@ export async function probe(conn, onStep = () => {}) {
106
120
  * 멀쩡해도 그렇다. 진단 화면은 그 400 을 「연결 실패」 로 적고, 사람은
107
121
  * 열쇠를 다시 받으러 간다 — 여덟 칸이 전부 그 한 줄 때문에 빨개진다.
108
122
  */
109
- const H = () => headersFor(auth, key, 더할머리(shape));
123
+ /*
124
+ * 진단도 **제 이름을 달고 나간다.**
125
+ *
126
+ * 여기서 나가는 것은 여덟아홉 번의 진짜 채팅 완성이다. 이름이 없으면
127
+ * 게이트웨이가 그 하나하나를 새 대화로 연다 — 대시보드에는 정체 모를
128
+ * 단발 요청이 아홉 줄 쌓이고, 그 줄들은 무엇을 하다 생긴 것인지 아무도
129
+ * 모른다. 「붙는지 봤다」 와 「누가 몰래 두드렸다」 가 화면에서 같아진다.
130
+ *
131
+ * 대화가 아니므로 이어받을 이름이 없다. 이 진단 한 번을 한 대화로 묶는
132
+ * 이름을 여기서 짓는다 — 담기는 것은 여전히 번호 하나뿐이다.
133
+ */
134
+ const 진단이름 = 세션이름짓기(진단번호());
135
+ const H = () => headersFor(auth, key, 더할머리(shape, 진단이름));
110
136
  /*
111
137
  * 물음표 뒤를 끝에 남겨야 한다 (adapter.js 의 주소붙이기).
112
138
  *
@@ -34,6 +34,7 @@
34
34
  // 캐시가 매번 새로 엮인다. 배워서 값이 바뀌는 것은 예외다 — 그건 안 그러면
35
35
  // 그 턴이 죽는 자리라, 한 번 흔들리는 값을 치른다.
36
36
  import { 벤더 } from './toolfit.js';
37
+ import { 인증서등록 } from './clientcert.js';
37
38
  import { 말 } from '../i18n/index.js';
38
39
 
39
40
  /** 우리 눈금. agent/effort.js 의 LEVELS 와 같은 차례다 (off 는 따로 다룬다). */
@@ -608,6 +609,15 @@ export function 카드합치기(기본, 남긴것) {
608
609
  */
609
610
  export function 전선붙이기(conn, 배움 = null) {
610
611
  if (!conn) return conn;
612
+ /*
613
+ * 이 게이트웨이가 우리 인증서를 요구하면 여기서 매어 둔다 (backend/clientcert.js).
614
+ *
615
+ * 문이 넷이라 각 문에서 따로 부르면 언젠가 한 곳이 빠진다 — 그런 일이
616
+ * 실제로 두 번 있었다(vision·열쇠받기). 네 문이 다 지나는 자리는 여기다.
617
+ * `/model` 로 프로필을 갈아타도 여기를 다시 지나므로, 옛 프로필의 인증서가
618
+ * 새 창구로 따라가지 않는다.
619
+ */
620
+ 인증서등록(conn.base, conn.인증서 ?? null);
611
621
  const 기본 = 기본카드(conn);
612
622
  let 남긴것 = null;
613
623
  try { 남긴것 = 배움?.아는전선?.(conn.model, conn.base, conn.kind) ?? null; } catch { 남긴것 = null; }
package/src/cmdnames.js CHANGED
@@ -54,6 +54,8 @@ export const 명령들 = {
54
54
  sessions: { arg: false },
55
55
  recall: { arg: true },
56
56
  mcp: { arg: false },
57
+ hooks: { arg: false },
58
+ agents: { arg: false },
57
59
  memory: { arg: true },
58
60
  level: { arg: true },
59
61
  bell: { arg: true },
package/src/commands.js CHANGED
@@ -30,6 +30,8 @@ import { 말, 말 as 옮긴말, 언어, 언어들, 언어정하기, 언어고르
30
30
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
31
31
  import { allowTemporarily } from './safety/network.js';
32
32
  import { chat, 규격이름, 더할머리 } from './backend/adapter.js';
33
+ import { 잠잠기본 } from './backend/http.js';
34
+ import { 인증서설정, 인증서말 } from './backend/clientcert.js';
33
35
  import { 알림채움 } from './backend/retry.js';
34
36
  import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
35
37
  import { 정한셸 } from './tools/shell.js';
@@ -242,6 +244,18 @@ export async function handle(line, session, ctx) {
242
244
  // 물어보러 가는 자리에도 전선 카드를 달아 준다 (backend/wire.js) —
243
245
  // 없으면 이 한 번만 생각 칸이 짐작으로 나가고, Opus 5 계열에서는 400 이다.
244
246
  전선붙이기(새conn, ctx?.배움);
247
+ /*
248
+ * 대화 이름도 이어받는다.
249
+ *
250
+ * 이건 남에게 한 번 물어보는 것이지 **다른 대화가 아니다.** 안 옮기면
251
+ * 게이트웨이가 이 한 번을 새 대화로 열고, 대시보드에서는 진행 중인
252
+ * 대화와 아무 관계 없는 줄로 떨어져 나간다 — 나중에 「이 답이 어디서
253
+ * 나왔나」 를 따라갈 실이 끊긴다.
254
+ *
255
+ * conn 을 새로 짓는 자리는 언제나 이걸 빠뜨린다. 열쇠받기·vision 을
256
+ * 연결적용()에서 빠뜨렸던 것과 같은 자리다.
257
+ */
258
+ 새conn.세션이름 = session.세션이름 ?? null;
245
259
  const 알림 = 알릴말(session.conn, 새conn);
246
260
  say('');
247
261
  say(알림.밖으로
@@ -1036,6 +1050,16 @@ export async function handle(line, session, ctx) {
1036
1050
  } else if (k.auth !== 'none') {
1037
1051
  say(` ${c.gray(pad(말('status.keyStore'), 10))} ${열쇠보관(load())}`);
1038
1052
  }
1053
+ /*
1054
+ * 우리 인증서로 붙고 있으면 그렇다고 적는다.
1055
+ *
1056
+ * 인증서를 둘 이상 가진 사람은 어느 것이 먹었는지 알 길이 없다 — 붙으면
1057
+ * 조용하고 안 붙으면 TLS 악수 실패 한 줄만 온다. **암호는 절대 안 적는다**
1058
+ * (backend/clientcert.js 의 인증서말).
1059
+ */
1060
+ if (session.conn.인증서) {
1061
+ say(` ${c.gray(pad(말('status.clientCert'), 10))} ${c.gray(인증서말(session.conn.인증서))}`);
1062
+ }
1039
1063
  say(` ${c.gray(pad(말('status.rules'), 10))} ${session.rules ? session.rules.name : 말('status.noRules')}`);
1040
1064
  // 파일이 있는데 못 읽은 것을 '없음' 으로 적으면, 규칙을 적어 둔 사람은
1041
1065
  // 걸려 있다고 믿는다. 안 걸린 채로 도는 것이 여기서 제일 나쁜 모양이다.
@@ -1269,12 +1293,30 @@ export async function handle(line, session, ctx) {
1269
1293
  }
1270
1294
 
1271
1295
  for (const s of 붙은것) {
1272
- const 상태 = s.살아있나() ? c.green('●') : c.red('○');
1296
+ /*
1297
+ * 세 가지 상태를 **갈라서** 적는다 (backend/mcp.js 의 지연 로딩).
1298
+ *
1299
+ * ● 떠 있다 지금 프로세스가 돌고 있다
1300
+ * ◐ 대기 적어 둔 목록으로 서 있다 — 그 도구를 부르면 뜬다
1301
+ * ○ 죽었다 띄웠는데 안 됐거나 도중에 죽었다
1302
+ *
1303
+ * 대기를 죽음으로 뭉개면 사람은 없는 탈을 고치러 간다. 반대로 대기를
1304
+ * 초록으로 적으면 「띄워 봤다」 는 말이 거짓이 된다.
1305
+ */
1306
+ const 상태 = s.살아있나() ? c.green('●') : (s.대기 ? c.yellow('◐') : c.red('○'));
1273
1307
  const 이름들 = s.도구.map((t) => t.name);
1274
1308
  say(` ${상태} ${c.bold(s.이름)} ${c.gray(s.정보?.name ? `${s.정보.name} ${s.정보.version ?? ''}` : s.설정.command)}`);
1275
1309
  say(` ${c.gray('도구 ' + s.도구.length + '개')} ${c.gray(clip(이름들.join(' · '), 60))}`);
1310
+ if (s.대기) say(` ${c.gray('아직 안 띄웠습니다 — 적어 둔 목록입니다. 이 도구를 부르면 그때 뜹니다.')}`);
1276
1311
  if (s.잘림) say(` ${mark.warn} ${c.gray(`${s.잘림}개는 뺐습니다 — 한 서버에 ${도구최대}개까지만 받습니다(컨텍스트가 줄어듭니다)`)}`);
1277
- if (!s.살아있나()) say(` ${c.red(s.죽음 ?? '죽었습니다')}`);
1312
+ if (s.달라짐) {
1313
+ const 것 = [
1314
+ s.달라짐.늘어난것?.length ? `늘어남 ${s.달라짐.늘어난것.join(' ')}` : null,
1315
+ s.달라짐.없어진것?.length ? `없어짐 ${s.달라짐.없어진것.join(' ')}` : null,
1316
+ ].filter(Boolean).join(' · ');
1317
+ say(` ${mark.warn} ${c.gray(`띄워 보니 목록이 달랐습니다 — ${것}`)}`);
1318
+ }
1319
+ if (!s.살아있나() && !s.대기) say(` ${c.red(s.죽음 ?? '죽었습니다')}`);
1278
1320
  }
1279
1321
  say('');
1280
1322
  say(` ${c.gray('모델에게는')} ${c.white('mcp__<서버>__<도구>')} ${c.gray('라는 이름으로 보입니다.')}`);
@@ -1288,6 +1330,97 @@ export async function handle(line, session, ctx) {
1288
1330
  return { handled: true };
1289
1331
  }
1290
1332
 
1333
+ /*
1334
+ * 적어 둔 훅을 보여 준다 (safety/hooks.js).
1335
+ *
1336
+ * /mcp 와 같은 자리다. 남의 프로그램을 돌리는 것을 켜 놓았으면, 무엇이
1337
+ * 켜져 있는지 볼 화면이 있어야 한다. 없으면 사람은 제가 무엇을 켜 뒀는지
1338
+ * 모른 채로 쓰게 되고, 그건 켠 적 없는 것과 똑같이 위험하다.
1339
+ *
1340
+ * **이번 판이 실제로 들고 있는 것**을 보여 준다. 파일을 다시 읽지 않는다 —
1341
+ * 파일은 대화 도중에 바뀔 수 있고, 그러면 이 화면과 실제로 도는 것이
1342
+ * 달라진다. 화면이 거짓말을 하느니 옛것을 보여 주는 편이 낫다.
1343
+ */
1344
+ case 'hooks':
1345
+ case '훅': {
1346
+ const { 자리들: 훅자리들, 프로젝트자리: 훅프로젝트자리, 이PC자리: 훅이PC자리 } = await import('./safety/hooks.js');
1347
+ const 것들 = ctx?.훅들 ?? [];
1348
+ rule('적어 둔 훅', 70);
1349
+
1350
+ if (!것들.length) {
1351
+ say(` ${c.gray('걸린 훅이 없습니다.')}`);
1352
+ say('');
1353
+ say(` ${c.gray('적는 자리 —')} ${c.white(훅프로젝트자리(session.root))} ${c.gray('(이 프로젝트)')}`);
1354
+ say(` ${c.gray(' ')} ${c.white(훅이PC자리())} ${c.gray('(이 PC 전체)')}`);
1355
+ say(` ${c.gray('{ "hooks": [ { "때": "도구전", "도구": "Bash", "명령": "python .deel/gate.py" } ] }')}`);
1356
+ say('');
1357
+ say(` ${c.gray('걸 수 있는 자리 —')} ${c.white(훅자리들.join(' · '))}`);
1358
+ say(` ${c.yellow('※')} ${c.gray('프로젝트 파일은 믿는 폴더에서만 읽습니다 (deel trust).')}`);
1359
+ say('');
1360
+ return { handled: true };
1361
+ }
1362
+
1363
+ for (const h of 것들) {
1364
+ const 막나 = h.자리 === '도구전' || h.자리 === '말전';
1365
+ say(` ${c.bold(h.자리)} ${c.gray(h.무늬글 === '*' ? '(모든 도구)' : h.무늬글)} ${c.gray(`· ${h.출처}`)}`);
1366
+ say(` ${c.white(clip(h.명령, 62))}`);
1367
+ say(` ${c.gray(`${Math.round(h.제한 / 1000)}초 안에 · `)}${막나
1368
+ ? c.gray(h.지나갈까 ? '고장나면 지나감 (적어 두셨습니다)' : '고장나면 막음')
1369
+ : c.gray('여기서는 못 막습니다 — 말만 전합니다')}`);
1370
+ }
1371
+ say('');
1372
+ say(` ${c.yellow('※')} ${c.gray('훅은 남의 프로그램을 돌립니다 —')} ${c.white(`작업 범위(${session.root}) 를 안 지킵니다.`)}`);
1373
+ say(` ${c.gray(' 무엇이 돌았고 무엇을 막았는지는')} ${c.white('.deel/audit.jsonl')} ${c.gray('에 남습니다.')}`);
1374
+ say(` ${c.gray(' 이번 판만 끄려면')} ${c.white('deel --no-hooks')} ${c.gray('· 아예 끄려면')} ${c.white('DEEL_HOOKS=off')}`);
1375
+ say('');
1376
+ return { handled: true };
1377
+ }
1378
+
1379
+ /*
1380
+ * 이름 붙인 하위 작업을 보여 준다 (agent/agents.js).
1381
+ *
1382
+ * 모델이 보는 것은 이름과 한 줄 설명뿐이다. 사람은 그 아래 — 어느 모드로,
1383
+ * 어느 모델에게, 어떤 도구만 쥐여 주는지 — 를 볼 수 있어야 한다. 그게
1384
+ * 안 보이면 「리뷰어를 시켰는데 왜 파일을 고쳤지」 를 알아낼 길이 없다.
1385
+ */
1386
+ case 'agents':
1387
+ case '에이전트': {
1388
+ const { 자리들: 에자리들 } = await import('./agent/agents.js');
1389
+ const 것들 = ctx?.에이전트들 ?? [];
1390
+ rule('이름 붙인 하위 작업', 70);
1391
+
1392
+ if (!것들.length) {
1393
+ say(` ${c.gray('정의해 둔 것이 없습니다.')}`);
1394
+ say('');
1395
+ say(` ${c.gray('적는 자리 —')} ${c.white(에자리들(session.root)[0])} ${c.gray('(이 프로젝트)')}`);
1396
+ say(` ${c.gray('파일 하나가 이름 하나입니다.')} ${c.white('리뷰어.json')}`);
1397
+ say(` ${c.gray('{ "설명": "고친 코드를 훑고 위험한 데만 짚는다",')}`);
1398
+ say(` ${c.gray(' "모드": "inspect", "도구": ["Read", "Grep", "Glob"] }')}`);
1399
+ say('');
1400
+ say(` ${c.gray('부를 때는')} ${c.white('Task({ agent: "리뷰어", … })')} ${c.gray('— 모델이 이름으로 고릅니다.')}`);
1401
+ say('');
1402
+ return { handled: true };
1403
+ }
1404
+
1405
+ for (const a of 것들) {
1406
+ say(` ${c.bold(a.이름)} ${c.gray(`· ${a.출처}`)}`);
1407
+ say(` ${c.white(clip(a.설명, 62))}`);
1408
+ const 곁들 = [
1409
+ a.모드 ? `모드 ${a.모드}` : null,
1410
+ a.모델 ? `모델 ${a.모델}` : null,
1411
+ a.걸음 ? `${a.걸음}걸음` : null,
1412
+ a.도구?.length ? `도구 ${a.도구.join(' ')}` : null,
1413
+ a.지침 ? `지침 ${a.지침.length}자` : null,
1414
+ ].filter(Boolean);
1415
+ if (곁들.length) say(` ${c.gray(곁들.join(' · '))}`);
1416
+ }
1417
+ say('');
1418
+ say(` ${c.gray('모델에게는')} ${c.white('Task({ agent: "<이름>", … })')} ${c.gray('로 보입니다 — 이름과 설명만 실립니다.')}`);
1419
+ say(` ${c.gray('도구는')} ${c.white('줄이기만')} ${c.gray('합니다. 지금 모드가 안 주는 것을 적어 두면 그것만 빠집니다.')}`);
1420
+ say('');
1421
+ return { handled: true };
1422
+ }
1423
+
1291
1424
  case 'thread':
1292
1425
  case '갈래': return 갈래명령(session, ctx, arg), { handled: true };
1293
1426
 
@@ -2794,6 +2927,10 @@ function 연결적용(session, p, ctx = null) {
2794
2927
  kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
2795
2928
  ctx: p.ctx, maxTokens: p.maxTokens ?? null,
2796
2929
  streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
2930
+ // 프로필마다 다를 수 있다 — 사내 게이트웨이는 잠잠 상한이 크고 로컬은 작다.
2931
+ 잠잠: p.잠잠 ?? p.streamIdleMs ?? 잠잠기본,
2932
+ // 프로필마다 인증서가 다르다. 안 갈아 끼우면 옛 프로필의 신원으로 붙는다.
2933
+ 인증서: 인증서설정(p),
2797
2934
  /*
2798
2935
  * ── 이 두 줄이 없어서 회사 토큰이 남의 창구로 나갔다 ────────────────
2799
2936
  *
package/src/completion.js CHANGED
@@ -41,6 +41,10 @@ export const 명령들 = [
41
41
  { 이름: 'audit', 뜻: '무엇을 했는지 기록을 본다', en: 'show the record of what it did' },
42
42
  { 이름: 'doc2md', 뜻: '문서·시안을 마크다운으로 (hwpx·docx·pptx·xlsx·pdf·fig)', en: 'document or design to Markdown (hwpx/docx/pptx/xlsx/pdf/fig)' },
43
43
  { 이름: 'sbom', 뜻: '무엇이 들었는지 목록을 낸다', en: 'list what is inside (SBOM)' },
44
+ { 이름: 'trust', 뜻: '이 폴더의 프로젝트 설정을 읽게 한다', en: "read this folder's project config" },
45
+ { 이름: 'stats', 뜻: '이 폴더에서 무엇을 했나 (감사기록 요약)', en: 'what happened in this folder (audit summary)' },
46
+ { 이름: 'config', 뜻: '설정 값이 어디서 왔는지 (config explain <칸>)', en: 'where a config value comes from (config explain <key>)' },
47
+ { 이름: 'rules', 뜻: '적어 둔 승인 규칙이 진짜 그렇게 도나 확인한다', en: 'check that the approval rules actually do what they say' },
44
48
  { 이름: 'scan', 뜻: '게이트웨이에 어떤 모델이 있나 훑는다', en: 'scan the gateway for available models' },
45
49
  { 이름: 'sessions', 뜻: '지난 대화 목록', en: 'list past conversations' },
46
50
  { 이름: 'ls', 뜻: 'sessions 와 같다', en: 'same as sessions' },
@@ -85,9 +89,15 @@ export const 깃발들 = [
85
89
  { 이름: '--yes', 뜻: '물어보지 않고 진행', en: 'do not ask, just proceed', 값: false },
86
90
  { 이름: '--hard', 뜻: 'reset all 에서 되돌리기·감사기록까지', en: 'with reset all: undo snapshots and the audit log too', 값: false },
87
91
  { 이름: '--json', 뜻: '결과를 JSON 으로', en: 'output JSON', 값: false },
92
+ { 이름: '--days', 뜻: 'stats 에서 볼 기간(일)', en: 'stats: how many days back', 값: null },
93
+ { 이름: '--all', 뜻: 'stats 에서 기간을 안 자름', en: 'stats: no time window', 값: false },
94
+ // 값이 파일이라 셸이 파일을 완성해 줘야 한다. 목록으로 못 준다 —
95
+ // 스키마 파일 이름은 사람이 짓는 것이라 우리가 알 수가 없다.
96
+ { 이름: '--output-schema', 뜻: '답을 이 JSON Schema 모양으로', en: 'answer must match this JSON Schema', 값: '파일' },
88
97
  { 이름: '--quiet', 뜻: '군말 없이', en: 'no chatter', 값: false },
89
98
  { 이름: '--tui', 뜻: '입력 상자를 켠다', en: 'force the input box on', 값: false },
90
99
  { 이름: '--no-tui', 뜻: '입력 상자 없이 줄 화면으로', en: 'plain line output, no input box', 값: false },
100
+ { 이름: '--no-hooks', 뜻: '이번 판만 훅을 끄고 돌린다', en: 'run this once with hooks off', 값: false },
91
101
  { 이름: '--continue', 뜻: '지난 대화 이어서', en: 'continue the last conversation', 값: null },
92
102
  { 이름: '--resume', 뜻: '고른 대화 이어서', en: 'resume a chosen conversation', 값: null },
93
103
  { 이름: '--version', 뜻: '판 번호', en: 'version number', 값: false },
@@ -102,13 +112,32 @@ const 깃발이름만 = () => 깃발들.map((x) => x.이름);
102
112
  /** 셸 문자열 안에 그대로 넣어도 되게. 우리 목록은 다 ASCII 라 넉넉하다. */
103
113
  const 홑따옴표 = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
104
114
 
115
+ /*
116
+ * 파워셸의 홑따옴표는 **셈법이 다르다.**
117
+ *
118
+ * bash 는 따옴표를 닫고 이스케이프한 따옴표를 붙였다 다시 여는 식이지만
119
+ * (`'\''`), 파워셸에서 그 글자열은 그냥 역슬래시와 따옴표다. 파워셸은
120
+ * 홑따옴표를 **두 번 적어서** 하나로 친다.
121
+ *
122
+ * 여태 안 터진 것은 설명 글에 아포스트로피가 하나도 없었기 때문이다.
123
+ * `read this folder's project config` 한 줄을 넣자마자 스크립트가 통째로
124
+ * 문법 오류가 났다 — 저장한 사람은 셸을 켤 때마다 빨간 글을 보고, 그게
125
+ * deel 때문인 줄도 모른다. 아포스트로피를 피해 글을 쓰는 것으로 때우면
126
+ * 다음에 적는 사람이 같은 데 빠진다. 따옴표는 따옴표 짓는 자리에서 짓는다.
127
+ */
128
+ const 파워셸따옴표 = (s) => `'${String(s).replace(/'/g, "''")}'`;
129
+
105
130
  function bash판({ zsh = false } = {}) {
106
131
  const 값깃발 = 깃발들.filter((x) => Array.isArray(x.값));
107
132
  const 폴더깃발 = 깃발들.filter((x) => x.값 === '폴더').map((x) => x.이름);
133
+ const 파일깃발 = 깃발들.filter((x) => x.값 === '파일').map((x) => x.이름);
108
134
  const 값갈래 = 값깃발.map((x) => ` ${x.이름}) COMPREPLY=( $(compgen -W ${홑따옴표(x.값.join(' '))} -- "$cur") ); return 0 ;;`).join('\n');
109
135
  const 폴더갈래 = 폴더깃발.length
110
136
  ? ` ${폴더깃발.join('|')}) COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;;`
111
137
  : '';
138
+ const 파일갈래 = 파일깃발.length
139
+ ? ` ${파일깃발.join('|')}) COMPREPLY=( $(compgen -f -- "$cur") ); return 0 ;;`
140
+ : '';
112
141
 
113
142
  return `# deel 탭 완성 (${zsh ? 'zsh' : 'bash'})
114
143
  #
@@ -125,6 +154,7 @@ _deel() {
125
154
  case "$prev" in
126
155
  ${값갈래}
127
156
  ${폴더갈래}
157
+ ${파일갈래}
128
158
  completion) COMPREPLY=( $(compgen -W ${홑따옴표(셸들.join(' '))} -- "$cur") ); return 0 ;;
129
159
  esac
130
160
 
@@ -179,11 +209,11 @@ complete -F _deel deel
179
209
  * (bash·zsh 완성은 설명을 아예 안 보여 주므로 잃는 것이 없다.)
180
210
  */
181
211
  function 파워셸판() {
182
- const 명령목록 = 명령들.map((x) => ` @{ name = ${홑따옴표(x.이름)}; help = ${홑따옴표(x.en)} }`).join('\n');
183
- const 깃발목록 = 깃발들.map((x) => ` @{ name = ${홑따옴표(x.이름)}; help = ${홑따옴표(x.en)} }`).join('\n');
212
+ const 명령목록 = 명령들.map((x) => ` @{ name = ${파워셸따옴표(x.이름)}; help = ${파워셸따옴표(x.en)} }`).join('\n');
213
+ const 깃발목록 = 깃발들.map((x) => ` @{ name = ${파워셸따옴표(x.이름)}; help = ${파워셸따옴표(x.en)} }`).join('\n');
184
214
  const 값목록 = 깃발들
185
215
  .filter((x) => Array.isArray(x.값))
186
- .map((x) => ` ${홑따옴표(x.이름)} = @(${x.값.map(홑따옴표).join(', ')})`)
216
+ .map((x) => ` ${파워셸따옴표(x.이름)} = @(${x.값.map(파워셸따옴표).join(', ')})`)
187
217
  .join('\n');
188
218
 
189
219
  // 파워셸은 완성 후보에 설명을 같이 실을 수 있다. 탭을 누르면 뜻이 같이 뜬다.
@@ -210,7 +240,7 @@ ${깃발목록}
210
240
  )
211
241
  $values = @{
212
242
  ${값목록}
213
- 'completion' = @(${셸들.map(홑따옴표).join(', ')})
243
+ 'completion' = @(${셸들.map(파워셸따옴표).join(', ')})
214
244
  }
215
245
 
216
246
  # Which word comes before the one being typed?
package/src/config.js CHANGED
@@ -1,4 +1,5 @@
1
- // 연결 프로필 저장/읽기. ~/.deel/config.json (프로젝트 폴더의 .deel/config.json 우선)
1
+ // 연결 프로필 저장/읽기. ~/.deel/config.json 위에 프로젝트의 .deel/config.json 겹친다
2
+ // (믿는 폴더일 때만 — safety/trust.js).
2
3
  import { homedir } from 'node:os';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
@@ -7,6 +8,7 @@ import { 셸정하기 } from './tools/shell.js';
7
8
  import { 애저정하기 } from './backend/azure.js';
8
9
  import { 정책읽기 } from './safety/policy.js';
9
10
  import { 잠그기, 풀기, 잠긴것인가, 쓸수있나, 보관방식 } from './safety/keystore.js';
11
+ import { 믿나, 프로젝트거르기 } from './safety/trust.js';
10
12
 
11
13
  // 설정이 놓이는 자리.
12
14
  //
@@ -33,9 +35,17 @@ function projectDir() {
33
35
  */
34
36
  export function homeDir() { return userDir(); }
35
37
 
38
+ /**
39
+ * 지금 실제로 쓰는 설정 파일.
40
+ *
41
+ * 프로젝트 설정이 있어도 **믿는 폴더일 때만** 그쪽을 준다 (safety/trust.js).
42
+ * 안 읽는 파일에 쓰지도 않아야 한다 — 안 믿어서 무시한 파일에 `deel setup`
43
+ * 이 값을 적으면, 다음에 켤 때 그 값이 또 무시된다. 사람은 두 번 적고 두 번
44
+ * 다 안 먹는 것을 보는데, 그때 무엇 때문인지 알 길이 없다.
45
+ */
36
46
  export function configPath() {
37
47
  const local = join(projectDir(), 'config.json');
38
- if (existsSync(local)) return local;
48
+ if (existsSync(local) && 믿나(process.cwd())) return local;
39
49
  return join(userDir(), 'config.json');
40
50
  }
41
51
 
@@ -133,15 +143,82 @@ function 정책덮기(cfg) {
133
143
  return cfg;
134
144
  }
135
145
 
146
+ function 한장읽기(p) {
147
+ try { return JSON.parse(readFileSync(p, 'utf8')); }
148
+ catch (err) { throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`); }
149
+ }
150
+
151
+ /*
152
+ * ── 두 장을 겹친다 ─────────────────────────────────────────────────────
153
+ *
154
+ * 예전에는 프로젝트 설정이 있으면 이 PC 설정을 **통째로 안 읽었다.** 그래서
155
+ * 저장소에 `.deel/config.json` 한 줄만 들어 있어도 그 폴더에서는 프로필이
156
+ * 하나도 없는 것이 됐다 — 「이 저장소는 code 모드로 연다」 를 적으려던 사람이
157
+ * 제 게이트웨이를 통째로 잃는다. 그러면 사람은 프로젝트 설정을 안 쓴다.
158
+ *
159
+ * 이제 겹친다. 규칙은 하나다 — **좁히는 쪽만 이긴다.** 금지(deny)는 두 장을
160
+ * 합치고, 허락(allow)은 애초에 프로젝트에서 안 읽는다(safety/trust.js).
161
+ */
162
+ function 겹치기(집, 방) {
163
+ const cfg = { ...집, ...방 };
164
+
165
+ // 프로필은 이름으로 맞춘다. 통째로 갈아치우면 이 PC 프로필이 사라진다.
166
+ const 이름표 = new Map((집.profiles ?? []).map((x) => [x?.name, x]));
167
+ for (const p of 방.profiles ?? []) {
168
+ if (!p?.name) continue;
169
+ 이름표.set(p.name, { ...(이름표.get(p.name) ?? {}), ...p });
170
+ }
171
+ cfg.profiles = [...이름표.values()];
172
+
173
+ // 금지는 합친다. 어느 쪽이 적었든 금지는 금지다.
174
+ const 금지 = [...(집.permissions?.deny ?? []), ...(방.permissions?.deny ?? [])];
175
+ cfg.permissions = { ...(집.permissions ?? {}), ...(방.permissions ?? {}) };
176
+ if (금지.length) cfg.permissions.deny = [...new Set(금지)];
177
+ // 허락은 이 PC 것만. 프로젝트가 적었어도 위에서 이미 걸러졌지만, 겹치는
178
+ // 자리에서 한 번 더 못 박는다 — 이 한 줄이 없으면 나중에 누가 프로젝트에
179
+ // allow 를 허용하도록 고쳤을 때 아무 데서도 안 걸린다.
180
+ if (Array.isArray(집.permissions?.allow)) cfg.permissions.allow = 집.permissions.allow;
181
+ else delete cfg.permissions.allow;
182
+ return cfg;
183
+ }
184
+
185
+ let 신뢰소식 = null; // 프로젝트 설정에 대해 할 말. 한 번 읽으면 지워진다.
186
+
187
+ /**
188
+ * 프로젝트 설정을 안 읽었거나 일부를 걷어냈으면 그것을 알려 준다.
189
+ *
190
+ * 조용히 무시하면 안 된다 — 적어 둔 사람은 걸린 줄 알고, 실제로는 안 걸린
191
+ * 채로 일이 돈다. 그 어긋남이 설정 전체를 못 믿게 만든다.
192
+ *
193
+ * @returns {{갈래:'안믿음', 자리:string, 폴더:string}
194
+ * |{갈래:'걸러냄', 자리:string, 걸러낸것:Array<{칸:string,왜:string}>}
195
+ * |null}
196
+ */
197
+ export function 프로젝트설정소식() { const s = 신뢰소식; 신뢰소식 = null; return s; }
198
+
136
199
  function 읽기() {
137
- const p = configPath();
138
- if (!existsSync(p)) return structuredClone(EMPTY);
139
- try {
140
- const raw = JSON.parse(readFileSync(p, 'utf8'));
141
- return { ...structuredClone(EMPTY), ...raw };
142
- } catch (err) {
143
- throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`);
200
+ const 집파일 = join(userDir(), 'config.json');
201
+ const 방파일 = join(projectDir(), 'config.json');
202
+ const 집 = existsSync(집파일)
203
+ ? { ...structuredClone(EMPTY), ...한장읽기(집파일) }
204
+ : structuredClone(EMPTY);
205
+ if (!existsSync(방파일)) return 집;
206
+
207
+ /*
208
+ * 안 믿는 폴더의 설정은 **안 읽는다.**
209
+ *
210
+ * 이 파일은 저장소에 딸려 온다. 남의 저장소 하나를 받은 것만으로 오간 말이
211
+ * 다른 주소로 가고, 열쇠받기 명령이 이 계정 권한으로 도는 길이 여기다.
212
+ * 그 명령은 도구 승인 화면보다 **앞**이라 어떤 승인 정책으로도 안 걸린다.
213
+ */
214
+ if (!믿나(process.cwd())) {
215
+ 신뢰소식 = { 갈래: '안믿음', 자리: 방파일, 폴더: process.cwd() };
216
+ return 집;
144
217
  }
218
+
219
+ const { 값: 방, 걸러낸것 } = 프로젝트거르기(한장읽기(방파일));
220
+ if (걸러낸것.length) 신뢰소식 = { 갈래: '걸러냄', 자리: 방파일, 걸러낸것 };
221
+ return 겹치기(집, 방);
145
222
  }
146
223
 
147
224
  export function save(cfg, { toProject = false } = {}) {