@triflux/core 10.0.0-alpha.2 → 10.0.1

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.
@@ -38,8 +38,12 @@
38
38
  "mcp_route": null
39
39
  },
40
40
  {
41
- "id": "tfx-codex-swarm",
41
+ "id": "tfx-swarm",
42
42
  "patterns": [
43
+ {
44
+ "source": "\\btfx[\\s-]?swarm\\b",
45
+ "flags": "i"
46
+ },
43
47
  {
44
48
  "source": "\\btfx[\\s-]?codex[\\s-]?swarm\\b",
45
49
  "flags": "i"
@@ -47,20 +51,7 @@
47
51
  {
48
52
  "source": "\\bcodex[\\s-]?swarm\\b",
49
53
  "flags": "i"
50
- }
51
- ],
52
- "skill": "tfx-codex-swarm",
53
- "priority": 1,
54
- "supersedes": [
55
- "tfx-codex"
56
- ],
57
- "exclusive": false,
58
- "state": null,
59
- "mcp_route": null
60
- },
61
- {
62
- "id": "tfx-remote-spawn",
63
- "patterns": [
54
+ },
64
55
  {
65
56
  "source": "\\btfx[\\s-]?remote[\\s-]?spawn\\b",
66
57
  "flags": "i"
@@ -70,9 +61,11 @@
70
61
  "flags": "i"
71
62
  }
72
63
  ],
73
- "skill": "tfx-remote-spawn",
64
+ "skill": "tfx-swarm",
74
65
  "priority": 1,
75
- "supersedes": [],
66
+ "supersedes": [
67
+ "tfx-codex"
68
+ ],
76
69
  "exclusive": false,
77
70
  "state": null,
78
71
  "mcp_route": null
@@ -26,6 +26,18 @@ const BLOCK_RULES = [
26
26
  { pattern: /\bpsmux\s+kill-server\b/i, reason: "psmux kill-server 차단 — 모든 세션이 즉시 종료됩니다. node hub/team/psmux.mjs kill-swarm 사용", skipIfGit: true },
27
27
  ];
28
28
 
29
+ const WT_DIRECT_PATTERNS = [
30
+ /\bwt\.exe\b/i,
31
+ /\bwt\s+new-tab\b/i,
32
+ /\bwt\s+split-pane\b/i,
33
+ /\bwt\s+-w\b/i,
34
+ /\bStart-Process\s+wt/i,
35
+ /\bStart-Process\s+['"]?wt\.exe/i,
36
+ ];
37
+
38
+ const WT_DIRECT_BLOCK_MESSAGE =
39
+ "[safety-guard] wt.exe 직접 호출 차단됨. → hub/team/wt-manager.mjs의 createTab() / splitPane() / applySplitLayout()을 사용하세요.";
40
+
29
41
  // ── 경고 규칙 ──────────────────────────────────────────────
30
42
  const WARN_RULES = [
31
43
  { pattern: /\bgit\s+push\b(?!.*--force)/i, warn: "git push 감지. 원격 저장소에 반영됩니다." },
@@ -46,6 +58,45 @@ function readStdin() {
46
58
  }
47
59
  }
48
60
 
61
+ function shouldSkipSegment(segment) {
62
+ return !segment || segment.startsWith("#") || /^\s*(echo|printf|grep|git\s+commit)\b/i.test(segment);
63
+ }
64
+
65
+ function hasSegmentInvocation(cmd, patterns) {
66
+ if (!patterns.some((pattern) => pattern.test(cmd))) return false;
67
+
68
+ const lines = cmd.split(/\n/);
69
+ let heredocDelimiter = null;
70
+ return lines.some((line) => {
71
+ if (heredocDelimiter !== null) {
72
+ if (line.trim() === heredocDelimiter) heredocDelimiter = null;
73
+ return false;
74
+ }
75
+
76
+ const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
77
+ if (heredocMatch) {
78
+ heredocDelimiter = heredocMatch[1];
79
+ return false;
80
+ }
81
+
82
+ const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
83
+ return segments.some((seg) => {
84
+ const trimmed = seg.trim();
85
+ if (shouldSkipSegment(trimmed)) return false;
86
+ return patterns.some((pattern) => pattern.test(trimmed));
87
+ });
88
+ });
89
+ }
90
+
91
+ function blockCommand(message, command) {
92
+ process.stderr.write(
93
+ `${message}\n` +
94
+ `명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
95
+ "이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요."
96
+ );
97
+ process.exit(2);
98
+ }
99
+
49
100
  function main() {
50
101
  const raw = readStdin();
51
102
  if (!raw.trim()) process.exit(0);
@@ -65,47 +116,22 @@ function main() {
65
116
  // psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
66
117
  // git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
67
118
  function isPsmuxInvocation(cmd) {
68
- // psmux kill-session/server가 명령에 없으면 즉시 false
69
- if (!/\bpsmux\s+kill-(session|server)\b/i.test(cmd)) return false;
70
-
71
- // 줄 분할 → heredoc 경계 추적 → 세그먼트 분할(&&, ;, ||)로 각 명령 단위 검사
72
- // 세그먼트가 echo/grep/git-commit으로 시작하면 인자 텍스트이므로 무시
73
- const lines = cmd.split(/\n/);
74
- let heredocDelimiter = null;
75
- return lines.some((line) => {
76
- // heredoc 내부이면 닫는 구분자인지만 확인하고 건너뜀
77
- if (heredocDelimiter !== null) {
78
- if (line.trim() === heredocDelimiter) heredocDelimiter = null;
79
- return false;
80
- }
81
- // heredoc 시작 감지: <<'WORD', <<"WORD", <<WORD
82
- const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
83
- if (heredocMatch) {
84
- heredocDelimiter = heredocMatch[1];
85
- // heredoc 시작 줄 자체는 실제 명령이므로 계속 검사하되
86
- // 시작 줄에 psmux kill이 포함될 리 없으므로 여기서 false 반환
87
- return false;
88
- }
89
- const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
90
- return segments.some((seg) => {
91
- const t = seg.trim();
92
- if (!t || t.startsWith("#")) return false;
93
- if (/^\s*(echo|printf|grep|git\s+commit)\b/i.test(t)) return false;
94
- return /\bpsmux\s+kill-(session|server)\b/i.test(t);
95
- });
96
- });
119
+ return hasSegmentInvocation(cmd, [/\bpsmux\s+kill-(session|server)\b/i]);
120
+ }
121
+
122
+ function isWtDirectInvocation(cmd) {
123
+ return hasSegmentInvocation(cmd, WT_DIRECT_PATTERNS);
124
+ }
125
+
126
+ if (isWtDirectInvocation(command)) {
127
+ blockCommand(WT_DIRECT_BLOCK_MESSAGE, command);
97
128
  }
98
129
 
99
130
  // 1. BLOCK 체크 — exit 2로 차단
100
131
  for (const rule of BLOCK_RULES) {
101
132
  if (rule.skipIfGit && !isPsmuxInvocation(command)) continue;
102
133
  if (rule.pattern.test(command)) {
103
- process.stderr.write(
104
- `[triflux safety-guard] BLOCKED: ${rule.reason}\n` +
105
- `명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
106
- `이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요.`
107
- );
108
- process.exit(2);
134
+ blockCommand(`[triflux safety-guard] BLOCKED: ${rule.reason}`, command);
109
135
  }
110
136
  }
111
137
 
@@ -8,6 +8,22 @@ import { join } from "node:path";
8
8
  /** hosts.json 캐시 (프로세스 수명 동안 유지) */
9
9
  let hostsCache = null;
10
10
 
11
+ /**
12
+ * hosts.json을 로드하여 캐시한다.
13
+ * @param {string} [repoRoot] — 프로젝트 루트 경로
14
+ */
15
+ function loadHostsCache(repoRoot) {
16
+ if (hostsCache) return;
17
+ try {
18
+ const hostsPath = repoRoot
19
+ ? join(repoRoot, "references", "hosts.json")
20
+ : join(process.cwd(), "references", "hosts.json");
21
+ hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
22
+ } catch {
23
+ hostsCache = { hosts: {} };
24
+ }
25
+ }
26
+
11
27
  /**
12
28
  * hosts.json에서 호스트 OS를 조회한다.
13
29
  * @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
@@ -15,16 +31,7 @@ let hostsCache = null;
15
31
  * @returns {"windows"|"posix"} 호스트 OS 유형
16
32
  */
17
33
  export function detectHostOs(hostAlias, repoRoot) {
18
- if (!hostsCache) {
19
- try {
20
- const hostsPath = repoRoot
21
- ? join(repoRoot, "references", "hosts.json")
22
- : join(process.cwd(), "references", "hosts.json");
23
- hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
24
- } catch {
25
- hostsCache = { hosts: {} };
26
- }
27
- }
34
+ loadHostsCache(repoRoot);
28
35
 
29
36
  const hostCfg = hostsCache.hosts?.[hostAlias];
30
37
  if (hostCfg?.os === "windows") return "windows";
@@ -88,12 +95,14 @@ export function nullDevice(os) {
88
95
  * @param {string[]} remoteCmd — 원격에서 실행할 명령 + 인자
89
96
  * @param {object} [opts]
90
97
  * @param {number} [opts.connectTimeout=5] — SSH 연결 타임아웃(초)
98
+ * @param {boolean} [opts.keepalive=true] — ServerAliveInterval/CountMax 활성화
91
99
  * @param {"windows"|"posix"} [opts.os] — 호스트 OS (미지정 시 자동 감지)
92
100
  * @param {string} [opts.repoRoot] — hosts.json 탐색 경로
93
101
  * @returns {string[]} execFileSync('ssh', returnValue) 형태로 사용
94
102
  */
95
103
  export function buildSshArgs(host, remoteCmd, opts = {}) {
96
104
  const connectTimeout = opts.connectTimeout ?? 5;
105
+ const keepalive = opts.keepalive !== false;
97
106
  const hostAlias = host.includes("@") ? host.split("@").pop() : host;
98
107
  const os = opts.os ?? detectHostOs(hostAlias, opts.repoRoot);
99
108
 
@@ -102,12 +111,17 @@ export function buildSshArgs(host, remoteCmd, opts = {}) {
102
111
  const quotedArgs = args.map((a) => shellQuoteForHost(a, os));
103
112
  const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
104
113
 
105
- return [
114
+ const sshArgs = [
106
115
  "-o", `ConnectTimeout=${connectTimeout}`,
107
116
  "-o", "BatchMode=yes",
108
- host,
109
- remoteCmdStr,
110
117
  ];
118
+
119
+ if (keepalive) {
120
+ sshArgs.push("-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3");
121
+ }
122
+
123
+ sshArgs.push(host, remoteCmdStr);
124
+ return sshArgs;
111
125
  }
112
126
 
113
127
  /**
@@ -144,6 +158,53 @@ export function validateCommandForOs(command, os) {
144
158
  return { safe: violations.length === 0, violations };
145
159
  }
146
160
 
161
+ /**
162
+ * hosts.json에서 호스트 전체 설정을 조회한다.
163
+ * @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
164
+ * @param {string} [repoRoot]
165
+ * @returns {object|null}
166
+ */
167
+ export function getHostConfig(hostAlias, repoRoot) {
168
+ loadHostsCache(repoRoot);
169
+ return hostsCache.hosts?.[hostAlias] ?? null;
170
+ }
171
+
172
+ /**
173
+ * 별칭(alias)으로 호스트 키를 찾는다.
174
+ * @param {string} alias — 호스트 키 또는 aliases 배열 내 값
175
+ * @param {string} [repoRoot]
176
+ * @returns {string|null} 호스트 키 (e.g. "ultra4") 또는 null
177
+ */
178
+ export function resolveHostAlias(alias, repoRoot) {
179
+ loadHostsCache(repoRoot);
180
+
181
+ if (hostsCache.hosts?.[alias]) return alias;
182
+
183
+ for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
184
+ if (cfg.aliases?.includes(alias)) return name;
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * 특정 capability를 보유한 호스트 목록을 반환한다.
192
+ * @param {string} capability — e.g. "codex", "claude", "gpu"
193
+ * @param {string} [repoRoot]
194
+ * @returns {Array<{ name: string, config: object }>}
195
+ */
196
+ export function selectHostForCapability(capability, repoRoot) {
197
+ loadHostsCache(repoRoot);
198
+
199
+ const matches = [];
200
+ for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
201
+ if (cfg.capabilities?.includes(capability)) {
202
+ matches.push({ name, config: cfg });
203
+ }
204
+ }
205
+ return matches;
206
+ }
207
+
147
208
  /** hosts.json 캐시 초기화 (테스트용) */
148
209
  export function resetHostsCache() {
149
210
  hostsCache = null;
@@ -0,0 +1,59 @@
1
+ // hub/lib/ssh-retry.mjs — SSH command retry with exponential backoff
2
+ // transient SSH 에러(connection reset, broken pipe 등)를 감지하여 자동 재시도한다.
3
+
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ const DEFAULT_MAX_RETRIES = 2;
7
+ const DEFAULT_BASE_DELAY_MS = 1000;
8
+
9
+ /** transient SSH 에러 패턴 */
10
+ const TRANSIENT_RE =
11
+ /connection (reset|refused|timed out)|broken pipe|network is unreachable|ssh_exchange_identification|kex_exchange_identification/i;
12
+
13
+ /**
14
+ * execFileSync('ssh', args) 를 retry 래핑한다.
15
+ * transient 에러만 재시도하고, 그 외 에러는 즉시 throw.
16
+ *
17
+ * @param {string[]} args — ssh 인자 배열
18
+ * @param {object} [opts] — execFileSync 옵션 + retry 설정
19
+ * @param {number} [opts.maxRetries=2]
20
+ * @param {number} [opts.baseDelayMs=1000]
21
+ * @returns {string|Buffer} ssh stdout
22
+ */
23
+ export function execSshWithRetry(args, opts = {}) {
24
+ const { maxRetries = DEFAULT_MAX_RETRIES, baseDelayMs = DEFAULT_BASE_DELAY_MS, ...execOpts } = opts;
25
+
26
+ let lastError;
27
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
28
+ try {
29
+ return execFileSync("ssh", args, execOpts);
30
+ } catch (err) {
31
+ lastError = err;
32
+ if (attempt < maxRetries && isTransientSshError(err)) {
33
+ sleepSync(baseDelayMs * 2 ** attempt);
34
+ continue;
35
+ }
36
+ throw err;
37
+ }
38
+ }
39
+ throw lastError;
40
+ }
41
+
42
+ /**
43
+ * transient SSH 에러인지 판별한다.
44
+ * @param {Error} err
45
+ * @returns {boolean}
46
+ */
47
+ export function isTransientSshError(err) {
48
+ const msg = String(err.stderr || err.message || "");
49
+ return TRANSIENT_RE.test(msg);
50
+ }
51
+
52
+ /**
53
+ * 동기 sleep (Atomics.wait 기반).
54
+ * @param {number} ms
55
+ */
56
+ function sleepSync(ms) {
57
+ if (ms <= 0) return;
58
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
59
+ }
package/hub/state.mjs CHANGED
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
8
- const STATE_FILE_NAME = 'hub-state.json';
8
+ const PID_FILE_NAME = 'hub.pid';
9
+ const LEGACY_STATE_FILE_NAME = 'hub-state.json';
9
10
  const LOCK_FILE_NAME = 'hub-start.lock';
10
11
 
11
12
  let heldLockPath = null;
@@ -17,7 +18,11 @@ function getStateDir(options = {}) {
17
18
  }
18
19
 
19
20
  function getStatePath(options = {}) {
20
- return join(getStateDir(options), STATE_FILE_NAME);
21
+ return options.statePath || join(getStateDir(options), PID_FILE_NAME);
22
+ }
23
+
24
+ function getLegacyStatePath(options = {}) {
25
+ return join(getStateDir(options), LEGACY_STATE_FILE_NAME);
21
26
  }
22
27
 
23
28
  function getLockPath(options = {}) {
@@ -59,6 +64,21 @@ function safeReplaceFile(tempPath, targetPath) {
59
64
  }
60
65
  }
61
66
 
67
+ function writeJsonFile(targetPath, payload) {
68
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
69
+ writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
70
+ safeReplaceFile(tempPath, targetPath);
71
+ }
72
+
73
+ function readJsonFile(filePath) {
74
+ try {
75
+ if (!existsSync(filePath)) return null;
76
+ return parseJson(readFileSync(filePath, 'utf8'), null);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
62
82
  /**
63
83
  * 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
64
84
  * 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
@@ -73,15 +93,14 @@ function safeReplaceFile(tempPath, targetPath) {
73
93
  * @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
74
94
  * @returns {object} 기록된 상태 데이터
75
95
  */
76
- export function writeState({ pid, port, version, sessionId, startedAt }, options = {}) {
96
+ export function writeState({ pid, port, version, sessionId, startedAt, ...rest }, options = {}) {
77
97
  const stateDir = getStateDir(options);
78
98
  const statePath = getStatePath(options);
79
- const tempPath = join(stateDir, `${STATE_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
80
- const payload = { pid, port, version, sessionId, startedAt };
99
+ const payload = { pid, port, version, sessionId, startedAt, ...rest };
81
100
 
82
101
  mkdirSync(stateDir, { recursive: true });
83
- writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
84
- safeReplaceFile(tempPath, statePath);
102
+ writeJsonFile(statePath, payload);
103
+ try { unlinkSync(getLegacyStatePath(options)); } catch {}
85
104
  return payload;
86
105
  }
87
106
 
@@ -93,13 +112,7 @@ export function writeState({ pid, port, version, sessionId, startedAt }, options
93
112
  * @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
94
113
  */
95
114
  export function readState(options = {}) {
96
- const statePath = getStatePath(options);
97
- try {
98
- if (!existsSync(statePath)) return null;
99
- return parseJson(readFileSync(statePath, 'utf8'), null);
100
- } catch {
101
- return null;
102
- }
115
+ return readJsonFile(getStatePath(options)) ?? readJsonFile(getLegacyStatePath(options));
103
116
  }
104
117
 
105
118
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.0.0-alpha.2",
3
+ "version": "10.0.1",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -9,7 +9,9 @@
9
9
  "./hub/*": "./hub/*",
10
10
  "./scripts/*": "./scripts/*"
11
11
  },
12
- "engines": { "node": ">=18.0.0" },
12
+ "engines": {
13
+ "node": ">=18.0.0"
14
+ },
13
15
  "dependencies": {
14
16
  "pino": "^10.3.1",
15
17
  "pino-pretty": "^13.1.3",
@@ -21,7 +23,13 @@
21
23
  "hooks",
22
24
  "hud"
23
25
  ],
24
- "keywords": ["claude-code", "cli-routing", "codex", "gemini", "triflux"],
26
+ "keywords": [
27
+ "claude-code",
28
+ "cli-routing",
29
+ "codex",
30
+ "gemini",
31
+ "triflux"
32
+ ],
25
33
  "author": "tellang",
26
34
  "license": "MIT",
27
35
  "repository": {
@@ -1,8 +1,9 @@
1
- import { readdirSync, readFileSync } from "node:fs";
2
- import { basename, extname, join, relative } from "node:path";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
3
3
 
4
4
  const IF_TAG_RE = /{{\s*(#if\s+([A-Za-z0-9_.-]+)|\/if)\s*}}/g;
5
- const INCLUDE_RE = /{{>\s*([A-Za-z0-9_./-]+)\s*}}/g;
5
+ const PARTIAL_RE = /{{>\s*([A-Za-z0-9_./-]+)\s*}}/g;
6
+ const FILE_INCLUDE_RE = /{{#include\s+([A-Za-z0-9_./-]+)\s*}}/g;
6
7
  const VARIABLE_RE = /{{\s*([A-Za-z0-9_.-]+)\s*}}/g;
7
8
  const FRONTMATTER_RE = /^---\r?\n(?:([\s\S]*?)\r?\n)?---\r?\n?/;
8
9
 
@@ -39,6 +40,44 @@ function parseScalar(raw) {
39
40
  return bool ?? value;
40
41
  }
41
42
 
43
+ function parseMultilineValue(lines, startIndex, marker) {
44
+ const fold = marker.startsWith(">");
45
+ const chunks = [];
46
+ let index = startIndex;
47
+
48
+ while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
49
+ index += 1;
50
+ chunks.push(lines[index].replace(/^\s+/, ""));
51
+ }
52
+
53
+ return {
54
+ index,
55
+ value: fold ? chunks.join(" ").trim() : chunks.join("\n").trim(),
56
+ };
57
+ }
58
+
59
+ function parseListValue(lines, startIndex) {
60
+ const items = [];
61
+ let index = startIndex;
62
+
63
+ while (index + 1 < lines.length) {
64
+ const nextLine = lines[index + 1];
65
+ const match = nextLine.match(/^\s*-\s+(.*)$/);
66
+ if (match) {
67
+ index += 1;
68
+ items.push(parseScalar(match[1]));
69
+ continue;
70
+ }
71
+ if (/^\s*$/.test(nextLine)) {
72
+ index += 1;
73
+ continue;
74
+ }
75
+ break;
76
+ }
77
+
78
+ return { index, value: items };
79
+ }
80
+
42
81
  function parseFrontmatterBlock(block) {
43
82
  const lines = block.split(/\r?\n/);
44
83
  const data = {};
@@ -53,16 +92,21 @@ function parseFrontmatterBlock(block) {
53
92
  const value = rawValue.trim();
54
93
 
55
94
  if (value === ">" || value === "|" || value === ">-" || value === "|-") {
56
- const fold = value.startsWith(">");
57
- const chunks = [];
58
- while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
59
- i += 1;
60
- chunks.push(lines[i].replace(/^\s+/, ""));
61
- }
62
- data[key] = fold ? chunks.join(" ").trim() : chunks.join("\n").trim();
95
+ const parsed = parseMultilineValue(lines, i, value);
96
+ data[key] = parsed.value;
97
+ i = parsed.index;
63
98
  continue;
64
99
  }
65
100
 
101
+ if (!value) {
102
+ const parsed = parseListValue(lines, i);
103
+ if (parsed.index !== i) {
104
+ data[key] = parsed.value;
105
+ i = parsed.index;
106
+ continue;
107
+ }
108
+ }
109
+
66
110
  data[key] = parseScalar(value);
67
111
  }
68
112
 
@@ -109,8 +153,8 @@ function evaluateConditionals(source, context) {
109
153
  return walk(0, false).output;
110
154
  }
111
155
 
112
- function renderIncludes(source, context, partials, includeStack) {
113
- return source.replace(INCLUDE_RE, (_full, partialName) => {
156
+ function renderPartials(source, context, partials, options, includeStack) {
157
+ return source.replace(PARTIAL_RE, (_full, partialName) => {
114
158
  const partial = partials[partialName];
115
159
  if (partial == null) {
116
160
  throw new Error(`Missing partial: ${partialName}`);
@@ -119,7 +163,53 @@ function renderIncludes(source, context, partials, includeStack) {
119
163
  const chain = [...includeStack, partialName].join(" -> ");
120
164
  throw new Error(`Circular partial include: ${chain}`);
121
165
  }
122
- return renderWithContext(partial, context, partials, [...includeStack, partialName]);
166
+ return renderWithContext(partial, context, partials, options, [
167
+ ...includeStack,
168
+ partialName,
169
+ ]);
170
+ });
171
+ }
172
+
173
+ function normalizeIncludeName(includeName) {
174
+ return includeName.replace(/\\/g, "/");
175
+ }
176
+
177
+ function resolveIncludeContent(includeName, options) {
178
+ const normalizedName = normalizeIncludeName(includeName);
179
+ if (Object.prototype.hasOwnProperty.call(options.includes, normalizedName)) {
180
+ return options.includes[normalizedName];
181
+ }
182
+ if (!options.includeBaseDir) {
183
+ throw new Error(`Missing include: ${normalizedName}`);
184
+ }
185
+
186
+ const baseDir = resolve(options.includeBaseDir);
187
+ const fullPath = resolve(baseDir, normalizedName);
188
+ const relativePath = relative(baseDir, fullPath);
189
+ const isInsideBaseDir =
190
+ relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
191
+
192
+ if (!isInsideBaseDir || !existsSync(fullPath)) {
193
+ throw new Error(`Missing include: ${normalizedName}`);
194
+ }
195
+
196
+ const content = readFileSync(fullPath, "utf8");
197
+ options.includes[normalizedName] = content;
198
+ return content;
199
+ }
200
+
201
+ function renderFileIncludes(source, context, partials, options, includeStack) {
202
+ return source.replace(FILE_INCLUDE_RE, (_full, includeName) => {
203
+ const normalizedName = normalizeIncludeName(includeName);
204
+ const content = resolveIncludeContent(normalizedName, options);
205
+ if (includeStack.includes(normalizedName)) {
206
+ const chain = [...includeStack, normalizedName].join(" -> ");
207
+ throw new Error(`Circular template include: ${chain}`);
208
+ }
209
+ return renderWithContext(content, context, partials, options, [
210
+ ...includeStack,
211
+ normalizedName,
212
+ ]);
123
213
  });
124
214
  }
125
215
 
@@ -133,14 +223,25 @@ function renderVariables(source, context) {
133
223
  });
134
224
  }
135
225
 
136
- function renderWithContext(source, context, partials, includeStack = []) {
226
+ function renderWithContext(source, context, partials, options, includeStack = []) {
137
227
  const afterIf = evaluateConditionals(source, context);
138
- const afterInclude = renderIncludes(afterIf, context, partials, includeStack);
139
- return renderVariables(afterInclude, context);
228
+ const afterPartials = renderPartials(afterIf, context, partials, options, includeStack);
229
+ const afterFileIncludes = renderFileIncludes(
230
+ afterPartials,
231
+ context,
232
+ partials,
233
+ options,
234
+ includeStack,
235
+ );
236
+ return renderVariables(afterFileIncludes, context);
140
237
  }
141
238
 
142
239
  function readAllTemplateFiles(rootDir, currentDir = rootDir) {
143
- const entries = readdirSync(currentDir, { withFileTypes: true });
240
+ if (!rootDir || !existsSync(rootDir)) return [];
241
+
242
+ const entries = readdirSync(currentDir, { withFileTypes: true }).sort((left, right) =>
243
+ left.name.localeCompare(right.name),
244
+ );
144
245
  const files = [];
145
246
 
146
247
  for (const entry of entries) {
@@ -217,6 +318,9 @@ export function loadTemplatePartials(partialsDir) {
217
318
  }
218
319
 
219
320
  export function renderSkillTemplate(template, context = {}, options = {}) {
220
- const { partials = {} } = options;
221
- return renderWithContext(template, context, partials);
321
+ const { partials = {}, includes = {}, includeBaseDir = "" } = options;
322
+ return renderWithContext(template, context, partials, {
323
+ includeBaseDir,
324
+ includes: { ...includes },
325
+ });
222
326
  }