elanous 0.2.7 → 0.2.8
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/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-3084c58c1725a40b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8894-7317998f6cb97d8c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f4d2ecbbae6088d6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/board/page-2053e49930c82748.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-d4b6df905f185055.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-a70ba27c33003dd0.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-18166f885d831d3e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-1c9d6cfe68b121c2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-147a328753d983e2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-799395a9df7d95aa.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-6a8158b71d1fe9d7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/{page-30533117beb53625.js → page-e7ed3d010153d818.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-c0c0d19d51342d3e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-2e002ad8faa43291.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-b672e02e4bec8deb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-9db2226f38c542fb.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-70ee65f5f7a151dc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-092e1bcf27d751cc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-eb52580c4b75b905.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-7464c1987dd76e6d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-dd107bf1c7d52a90.js → page-e573437b0a1c728f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-085847b088675978.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/page-9fe9d38c36999eb4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/page-295a02b1026b8255.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/{page-56adbe1a1f1379bd.js → page-82efa177a30f3741.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-2e367075ffdbc062.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-647ed2b66c1d49f1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +1 -0
- package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +13 -13
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +13 -13
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +13 -13
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +13 -13
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +13 -13
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +13 -13
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +13 -13
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +13 -13
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +13 -13
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +13 -13
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +13 -13
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +13 -13
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +13 -13
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +13 -13
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +13 -13
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +13 -13
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +13 -13
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +13 -13
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +13 -13
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +14 -14
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +13 -13
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +13 -13
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +13 -13
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +13 -13
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +13 -13
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +13 -13
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +13 -13
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +13 -13
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +13 -13
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +13 -13
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +13 -13
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +13 -13
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +13 -13
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +13 -13
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +13 -13
- package/graphs/test-diet/recipes.yaml +9 -0
- package/graphs/test-diet/test-diet.yaml +22 -0
- package/package.json +2 -1
- package/persona-presets/_index.yaml +10 -0
- package/persona-presets/_schema.json +146 -0
- package/persona-presets/dana.yaml +26 -0
- package/persona-presets/haru.yaml +27 -0
- package/persona-presets/kai.yaml +27 -0
- package/persona-presets/leo.yaml +27 -0
- package/persona-presets/mira.yaml +30 -0
- package/persona-presets/nari.yaml +27 -0
- package/persona-presets/sora.yaml +27 -0
- package/persona-presets/yuna.yaml +27 -0
- package/plugins/geo-check/graphs/run-step.ts +34 -10
- package/scripts/field-feed/deliver-node.ts +8 -3
- package/scripts/field-feed/lib.ts +2 -0
- package/scripts/install.sh +20 -11
- package/scripts/persona-presets/lint.ts +79 -0
- package/scripts/release-loop/checklist-gate-node.ts +2 -2
- package/scripts/release-loop/gate-node.ts +27 -3
- package/scripts/release-loop/npm-publish-node.ts +10 -4
- package/scripts/release-loop/ops-upgrade-node.ts +11 -3
- package/scripts/release-loop/publish-node.ts +23 -1
- package/scripts/release-loop/shard-plan.ts +17 -0
- package/scripts/storyboard/storyboard.ts +167 -0
- package/scripts/test-diet/lib.ts +88 -0
- package/scripts/test-diet/node.ts +102 -0
- package/src/acp/auth-methods.ts +30 -0
- package/src/acp/server.ts +5 -5
- package/src/channel-import/detect.ts +191 -0
- package/src/channel-import/run.ts +111 -0
- package/src/cli/config-test-sync.ts +4 -0
- package/src/cli/decisions-cli.ts +13 -3
- package/src/cli/doctor-bundle.ts +128 -0
- package/src/cli/doctor-cli.ts +36 -1
- package/src/cli/doctor-local-llm.ts +95 -0
- package/src/cli/import-cli.ts +60 -0
- package/src/cli/mcp-login.ts +13 -4
- package/src/cli/persona-cli.ts +116 -0
- package/src/cli/release-cli.ts +8 -7
- package/src/cli/skills-cli.ts +173 -0
- package/src/cli/start-cli.ts +13 -4
- package/src/codex/setup.ts +15 -13
- package/src/dashboard/index.ts +17 -1
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -2
- package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
- package/src/decisions/decision-cards.ts +291 -0
- package/src/decisions/decision-ledger.ts +5 -2
- package/src/decisions/discord-decision-cards.ts +133 -0
- package/src/decisions/telegram-decision-cards.ts +100 -0
- package/src/directives/version-at.ts +77 -0
- package/src/discord.ts +17 -0
- package/src/domains/outbound-alert.ts +44 -1
- package/src/domains/repeated-failure.ts +1 -1
- package/src/error-report/report.ts +200 -0
- package/src/exec-requests/planner.ts +8 -2
- package/src/exec-requests/runner.ts +64 -24
- package/src/exec-requests/store.ts +2 -0
- package/src/field/field-feed-auto.ts +48 -0
- package/src/field/field-reel.ts +8 -1
- package/src/hooks/error-report.ts +210 -0
- package/src/hooks/receiver.ts +41 -1
- package/src/import/apply.ts +128 -0
- package/src/import/plan.ts +197 -0
- package/src/index.ts +82 -7
- package/src/input-core/text-entry.ts +13 -5
- package/src/intake-plane/seat-answer.ts +96 -0
- package/src/intake-plane/seat-doc-route.ts +17 -3
- package/src/nexus/api/consult-requests.ts +19 -8
- package/src/nexus/api/error-reports.ts +108 -0
- package/src/nexus/api/http-server.ts +21 -1
- package/src/nexus/api/outbound-report.ts +7 -0
- package/src/nexus/api/running-runs-background.ts +68 -0
- package/src/nexus/api/running-runs-child.ts +6 -0
- package/src/nexus/api/share-captures.ts +91 -0
- package/src/nexus/api/skill-problems.ts +56 -0
- package/src/nexus/api/terminals.ts +24 -8
- package/src/nexus/index.ts +25 -1
- package/src/nexus/setup-status.ts +7 -2
- package/src/oauth/browser-availability.ts +14 -0
- package/src/oauth/codex-quota-policy.ts +16 -0
- package/src/onboarding/continue-here.ts +13 -8
- package/src/onboarding/web-first.ts +8 -1
- package/src/onboarding.ts +66 -12
- package/src/persona/presets.ts +105 -0
- package/src/release-loop/checklist.ts +28 -5
- package/src/self-dev/orchestrate.ts +18 -11
- package/src/shell-env-bootstrap.ts +44 -7
- package/src/skills/connect-claude-plugin.ts +142 -0
- package/src/skills/connect.ts +70 -0
- package/src/skills/index.ts +29 -1
- package/src/skills/repair.ts +124 -0
- package/src/skills/runner.ts +11 -2
- package/src/skills/trigger-extract.ts +1 -1
- package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
- package/src/telegram-commands.ts +13 -0
- package/src/telegram.ts +34 -0
- package/src/user-config.ts +111 -13
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-80497e93a878ddc5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-c1c95739c0bff3b8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-71d18eec33b9c667.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-2c4e59079ee357ac.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
- package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
- /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// EN8 — `elanous connect <플러그인 폴더>`: Claude Code 플러그인(`.claude-plugin/plugin.json`)을 «그대로» 쓴다.
|
|
2
|
+
// 스킬 폴더는 EN9 와 같은 칸(`skills.sources[]` · kind connected · id `plugin:<이름>`)에 등록하고(복사 없음), `.mcp.json` 의 서버는 «꺼진 채»(`enabled:false`)
|
|
3
|
+
// `mcp.servers[]` 에 넣는다 — 켜기는 사람이 확인한 뒤에 한다. 환경변수가 필요한 서버는 등록하지 않고 이유를 말한다.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { findSkillFolders } from './connect.js';
|
|
7
|
+
|
|
8
|
+
export const CLAUDE_PLUGIN_MANIFEST = join('.claude-plugin', 'plugin.json');
|
|
9
|
+
|
|
10
|
+
export function isClaudePluginDir(dir: string): boolean {
|
|
11
|
+
return existsSync(join(dir, CLAUDE_PLUGIN_MANIFEST));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What we write into `mcp.servers[]` (raw config shape — parsed by parseMcpServerSpec). */
|
|
15
|
+
export type PluginMcpServer =
|
|
16
|
+
| { id: string; command: string[]; enabled: false }
|
|
17
|
+
| { id: string; transport: 'http'; url: string; enabled: false };
|
|
18
|
+
|
|
19
|
+
export interface ClaudePlugin {
|
|
20
|
+
root: string;
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
/** Skill folders that hold at least one SKILL.md folder. */
|
|
24
|
+
skillRoots: string[];
|
|
25
|
+
skills: string[];
|
|
26
|
+
mcp: PluginMcpServer[];
|
|
27
|
+
skipped: { name: string; reason: string }[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJson(path: string): unknown {
|
|
31
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asObject(value: unknown): Record<string, unknown> | null {
|
|
35
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serverId(plugin: string, name: string): string {
|
|
39
|
+
return `${plugin}-${name}`.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expandRoot(value: string, root: string): string {
|
|
43
|
+
return value.split('${CLAUDE_PLUGIN_ROOT}').join(root);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One `.mcp.json` entry → a server spec, or the reason it is not registered. */
|
|
47
|
+
export function convertMcpEntry(plugin: string, name: string, raw: unknown, root: string): PluginMcpServer | { skipped: string } {
|
|
48
|
+
const entry = asObject(raw);
|
|
49
|
+
if (!entry) return { skipped: '형식을 읽지 못했습니다' };
|
|
50
|
+
const env = asObject(entry.env);
|
|
51
|
+
if (env && Object.keys(env).length) return { skipped: `환경변수가 필요합니다(${Object.keys(env).join(', ')}) — 직접 등록해 주세요` };
|
|
52
|
+
const id = serverId(plugin, name);
|
|
53
|
+
const type = typeof entry.type === 'string' ? entry.type : undefined;
|
|
54
|
+
if (typeof entry.url === 'string' && entry.url) {
|
|
55
|
+
if (type && type !== 'http') return { skipped: `${type} 방식은 아직 지원하지 않습니다` };
|
|
56
|
+
if (entry.headers && Object.keys(asObject(entry.headers) ?? {}).length) return { skipped: '헤더(인증)가 필요합니다 — 직접 등록해 주세요' };
|
|
57
|
+
return { id, transport: 'http', url: entry.url, enabled: false };
|
|
58
|
+
}
|
|
59
|
+
if (typeof entry.command === 'string' && entry.command) {
|
|
60
|
+
const args = Array.isArray(entry.args) ? entry.args.filter((a): a is string => typeof a === 'string') : [];
|
|
61
|
+
return { id, command: [expandRoot(entry.command, root), ...args.map((a) => expandRoot(a, root))], enabled: false };
|
|
62
|
+
}
|
|
63
|
+
return { skipped: '실행 명령이나 주소가 없습니다' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mcpSource(root: string, manifest: Record<string, unknown>): Record<string, unknown> | null {
|
|
67
|
+
const declared = manifest.mcpServers;
|
|
68
|
+
let raw: unknown = null;
|
|
69
|
+
if (typeof declared === 'string') {
|
|
70
|
+
const path = resolve(root, declared);
|
|
71
|
+
if (path.startsWith(`${root}/`) && existsSync(path)) raw = readJson(path);
|
|
72
|
+
} else if (asObject(declared)) {
|
|
73
|
+
raw = declared;
|
|
74
|
+
} else if (existsSync(join(root, '.mcp.json'))) {
|
|
75
|
+
raw = readJson(join(root, '.mcp.json'));
|
|
76
|
+
}
|
|
77
|
+
const object = asObject(raw);
|
|
78
|
+
if (!object) return null;
|
|
79
|
+
return asObject(object.mcpServers) ?? object;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function skillRootsOf(root: string, manifest: Record<string, unknown>): string[] {
|
|
83
|
+
const declared = typeof manifest.skills === 'string' ? [manifest.skills]
|
|
84
|
+
: Array.isArray(manifest.skills) ? manifest.skills.filter((s): s is string => typeof s === 'string') : [];
|
|
85
|
+
const candidates = [join(root, 'skills'), ...declared.map((rel) => resolve(root, rel))]
|
|
86
|
+
.filter((path) => path === root || path.startsWith(`${root}/`));
|
|
87
|
+
return [...new Set(candidates)].filter((path) => findSkillFolders(path).length > 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read a Claude Code plugin folder. Throws only when the manifest is not JSON. */
|
|
91
|
+
export function readClaudePlugin(dir: string): ClaudePlugin {
|
|
92
|
+
const root = resolve(dir);
|
|
93
|
+
const manifest = asObject(readJson(join(root, CLAUDE_PLUGIN_MANIFEST))) ?? {};
|
|
94
|
+
const name = typeof manifest.name === 'string' && manifest.name.trim() ? manifest.name.trim() : root.split('/').pop() ?? 'plugin';
|
|
95
|
+
const version = typeof manifest.version === 'string' ? manifest.version : '0.0.0';
|
|
96
|
+
const skillRoots = skillRootsOf(root, manifest);
|
|
97
|
+
const skills = skillRoots.flatMap(findSkillFolders).sort();
|
|
98
|
+
const mcp: PluginMcpServer[] = [];
|
|
99
|
+
const skipped: ClaudePlugin['skipped'] = [];
|
|
100
|
+
for (const [server, raw] of Object.entries(mcpSource(root, manifest) ?? {})) {
|
|
101
|
+
const converted = convertMcpEntry(name, server, raw, root);
|
|
102
|
+
if ('skipped' in converted) skipped.push({ name: server, reason: converted.skipped });
|
|
103
|
+
else mcp.push(converted);
|
|
104
|
+
}
|
|
105
|
+
return { root, name, version, skillRoots, skills, mcp, skipped };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ClaudePluginPlan {
|
|
109
|
+
plugin: ClaudePlugin;
|
|
110
|
+
newSkillRoots: string[];
|
|
111
|
+
newMcp: PluginMcpServer[];
|
|
112
|
+
state: 'new' | 'already' | 'empty';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function planClaudePlugin(plugin: ClaudePlugin, activeDirs: readonly string[], mcpIds: readonly string[]): ClaudePluginPlan {
|
|
116
|
+
const newSkillRoots = plugin.skillRoots.filter((dir) => !activeDirs.includes(dir));
|
|
117
|
+
const newMcp = plugin.mcp.filter((server) => !mcpIds.includes(server.id));
|
|
118
|
+
const state = !plugin.skills.length && !plugin.mcp.length ? 'empty' : newSkillRoots.length || newMcp.length ? 'new' : 'already';
|
|
119
|
+
return { plugin, newSkillRoots, newMcp, state };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function claudePluginPrompt(plan: ClaudePluginPlan): string {
|
|
123
|
+
const { plugin } = plan;
|
|
124
|
+
const head = `Claude 플러그인 «${plugin.name}» ${plugin.version}`;
|
|
125
|
+
const skipped = plugin.skipped.map((s) => `\n · MCP ${s.name}: ${s.reason}`).join('');
|
|
126
|
+
if (plan.state === 'empty') return `${head}에서 쓸 스킬이나 MCP 서버를 찾지 못했습니다.${skipped}`;
|
|
127
|
+
if (plan.state === 'already') return `${head}은 이미 연결돼 있습니다 — 스킬 ${plugin.skills.length}개 · MCP ${plugin.mcp.length}개.${skipped}`;
|
|
128
|
+
const parts = [
|
|
129
|
+
plugin.skills.length ? `스킬 ${plugin.skills.length}개(그 자리를 그대로 씁니다)` : '',
|
|
130
|
+
plan.newMcp.length ? `MCP 서버 ${plan.newMcp.length}개(꺼진 채로 등록 — 켜기는 따로 확인합니다)` : '',
|
|
131
|
+
].filter(Boolean).join(' · ');
|
|
132
|
+
return `${head}: ${parts}${skipped}\n가져올까요? [Y/n] `;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** How to turn one registered server on — shown after connecting, never done for the person. */
|
|
136
|
+
export function mcpEnableHint(servers: readonly PluginMcpServer[], all: readonly { id?: unknown }[]): string[] {
|
|
137
|
+
return servers.map((server) => {
|
|
138
|
+
const index = all.findIndex((row) => row.id === server.id);
|
|
139
|
+
const what = 'url' in server ? server.url : server.command.join(' ');
|
|
140
|
+
return ` · ${server.id} — ${what}\n 켜려면: elanous config set mcp.servers.${index}.enabled true`;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// EN9 — `elanous connect <claude|codex>`: 쓰던 에이전트의 스킬을 찾아 «복사가 아니라 등록»한다(대표 «기존 플러그인을 그대로 쓸 수 있다»).
|
|
2
|
+
// 등록 = `skills.sources[]`(EN5 한 모델)에 kind `connected` 로 원본 경로를 더한다 · 원본은 읽기만 하고 고치지 않는다 · 읽지 못한 스킬은 SK2(`skills repair`)로 안내한다.
|
|
3
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { skillSourceId, type SkillSource } from '../user-config.js';
|
|
7
|
+
|
|
8
|
+
export type ConnectAgent = 'claude' | 'codex';
|
|
9
|
+
export const CONNECT_AGENTS: readonly ConnectAgent[] = ['claude', 'codex'];
|
|
10
|
+
|
|
11
|
+
const AGENT_LABEL: Record<ConnectAgent, string> = { claude: 'Claude Code', codex: 'Codex' };
|
|
12
|
+
|
|
13
|
+
export function connectSkillRoot(agent: ConnectAgent, home = homedir()): string {
|
|
14
|
+
return agent === 'claude' ? join(home, '.claude', 'skills') : join(home, '.codex', 'skills');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Folders that hold a SKILL.md (one level, like the skill index). Hidden folders such as `.system` are skipped. */
|
|
18
|
+
export function findSkillFolders(root: string): string[] {
|
|
19
|
+
try {
|
|
20
|
+
return readdirSync(root)
|
|
21
|
+
.filter((name) => !name.startsWith('.'))
|
|
22
|
+
.filter((name) => {
|
|
23
|
+
try { return statSync(join(root, name)).isDirectory() && existsSync(join(root, name, 'SKILL.md')); }
|
|
24
|
+
catch { return false; }
|
|
25
|
+
})
|
|
26
|
+
.sort();
|
|
27
|
+
} catch { return []; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ConnectPlan =
|
|
31
|
+
| { agent: ConnectAgent; root: string; state: 'missing' }
|
|
32
|
+
| { agent: ConnectAgent; root: string; state: 'empty' }
|
|
33
|
+
| { agent: ConnectAgent; root: string; state: 'already'; skills: string[] }
|
|
34
|
+
| { agent: ConnectAgent; root: string; state: 'new'; skills: string[] };
|
|
35
|
+
|
|
36
|
+
/** `activeDirs` = the skill directories elanous reads today (defaultSkillDirs). */
|
|
37
|
+
export function planConnect(agent: ConnectAgent, activeDirs: readonly string[], home = homedir()): ConnectPlan {
|
|
38
|
+
const root = connectSkillRoot(agent, home);
|
|
39
|
+
if (!existsSync(root)) return { agent, root, state: 'missing' };
|
|
40
|
+
const skills = findSkillFolders(root);
|
|
41
|
+
if (!skills.length) return { agent, root, state: 'empty' };
|
|
42
|
+
return activeDirs.includes(root) ? { agent, root, state: 'already', skills } : { agent, root, state: 'new', skills };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sample(skills: readonly string[]): string {
|
|
46
|
+
const head = skills.slice(0, 5).join(', ');
|
|
47
|
+
return skills.length > 5 ? `${head} 외 ${skills.length - 5}개` : head;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The first screen — one question, or one line saying why there is nothing to ask. */
|
|
51
|
+
export function connectPrompt(plan: ConnectPlan): string {
|
|
52
|
+
const who = AGENT_LABEL[plan.agent];
|
|
53
|
+
switch (plan.state) {
|
|
54
|
+
case 'missing': return `${who} 스킬 폴더가 없습니다(${plan.root}). 가져올 것이 없습니다.`;
|
|
55
|
+
case 'empty': return `${who} 스킬 폴더에 스킬이 없습니다(${plan.root}).`;
|
|
56
|
+
case 'already': return `${who}에서 쓰던 스킬 ${plan.skills.length}개는 이미 연결돼 있습니다 — ${sample(plan.skills)}`;
|
|
57
|
+
case 'new': return `${who}에서 쓰던 스킬 ${plan.skills.length}개를 찾았습니다 — ${sample(plan.skills)}\n복사하지 않고 그 자리(${plan.root})를 그대로 씁니다. 가져올까요? [Y/n] `;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns the new connected-source list (unchanged when the path is already there). */
|
|
62
|
+
export function withConnectedSource(sources: readonly SkillSource[] | undefined, path: string, id = skillSourceId(path)): SkillSource[] {
|
|
63
|
+
const list = [...(sources ?? [])];
|
|
64
|
+
if (!list.some((source) => source.path === path)) list.push({ id, path, kind: 'connected', enabled: true });
|
|
65
|
+
return list;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function withoutSources(sources: readonly SkillSource[] | undefined, paths: readonly string[]): SkillSource[] {
|
|
69
|
+
return (sources ?? []).filter((source) => !paths.includes(source.path));
|
|
70
|
+
}
|
package/src/skills/index.ts
CHANGED
|
@@ -93,6 +93,17 @@ export function nameTriggers(name: string): string[] {
|
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** A skill the index could not read — surfaced to the person (startup notice · repair offer), never swallowed. */
|
|
97
|
+
export interface SkillIndexProblem { name: string; dir: string; code: string | null; error: string }
|
|
98
|
+
const skillProblems = new Map<string, SkillIndexProblem>();
|
|
99
|
+
function recordSkillIndexProblem(problem: SkillIndexProblem): void {
|
|
100
|
+
skillProblems.set(`${problem.dir}\u0000${problem.name}`, problem);
|
|
101
|
+
}
|
|
102
|
+
/** Skills skipped by the most recent index builds (deduped by dir + name). */
|
|
103
|
+
export function skillIndexProblems(): SkillIndexProblem[] {
|
|
104
|
+
return [...skillProblems.values()];
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
function makeEntry(name: string, rootDir: string): SkillIndexEntry | null {
|
|
97
108
|
let m: ReturnType<typeof parseSkillMd>;
|
|
98
109
|
try {
|
|
@@ -458,9 +469,26 @@ export function buildSkillIndex(
|
|
|
458
469
|
const out: SkillIndexEntry[] = [];
|
|
459
470
|
const seenNames = new Set<string>();
|
|
460
471
|
for (const dir of dirs) {
|
|
472
|
+
// Problems reflect the latest build of each dir — a repaired skill must drop off the list (SK2).
|
|
473
|
+
for (const key of [...skillProblems.keys()]) if (key.startsWith(`${dir}\u0000`)) skillProblems.delete(key);
|
|
461
474
|
const names = listSkillNames(dir);
|
|
462
475
|
for (const name of names) {
|
|
463
|
-
|
|
476
|
+
// One malformed third-party skill must not take down the index (and with it the first screen).
|
|
477
|
+
let entry: SkillIndexEntry | null;
|
|
478
|
+
try {
|
|
479
|
+
entry = makeEntry(name, dir);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
// 대표 10-01: a broken skill must never crash the product — and must never vanish silently either (G9 P5a).
|
|
482
|
+
// It is skipped, recorded in skillIndexProblems() (a startup notice offers to repair it), and logged.
|
|
483
|
+
const problem: SkillIndexProblem = {
|
|
484
|
+
name, dir,
|
|
485
|
+
code: (err as NodeJS.ErrnoException)?.code ?? null,
|
|
486
|
+
error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200),
|
|
487
|
+
};
|
|
488
|
+
recordSkillIndexProblem(problem);
|
|
489
|
+
debug.log('skills.index', 'skill-skipped', { ...problem }, { level: 'warn' });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
464
492
|
if (!entry) continue;
|
|
465
493
|
if (seenNames.has(entry.name)) continue; // first-wins dedup
|
|
466
494
|
seenNames.add(entry.name);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SK2 — 남의 SKILL.md 를 색인이 못 읽으면(skillIndexProblems) 시스템 에러 대신 «안내 ⊕ 확인 뒤 수리».
|
|
2
|
+
// 대표 10-01: «시스템 에러가 아니라 별도 시스템 메시지를 받고 스킬을 수리» · «PR 이 아니라 접근한 서피스에서 간단히».
|
|
3
|
+
// 고칠 수 있는 것만 고친다: 한 줄이어야 하는 프론트매터 값이 YAML 목록·인라인 배열이면 한 줄로 편다.
|
|
4
|
+
// 권한 오류는 원인·명령만 안내한다. 고치기 전 원본은 옆에 `.bak` 으로 남기고, 다시 읽어 안 풀리면 되돌린다.
|
|
5
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { debug } from '../debug/log.js';
|
|
8
|
+
import { buildSkillIndex, skillIndexProblems, type SkillIndexProblem } from './index.js';
|
|
9
|
+
|
|
10
|
+
/** Frontmatter keys the index reads as one-line text. */
|
|
11
|
+
const SCALAR_KEYS = ['name', 'description', 'version', 'when_to_use', 'when-to-use', 'argument-hint', 'model', 'category'];
|
|
12
|
+
|
|
13
|
+
export type SkillRepairPlan =
|
|
14
|
+
| { kind: 'fixable'; problem: SkillIndexProblem; path: string; keys: string[] }
|
|
15
|
+
| { kind: 'fixable-permission'; problem: SkillIndexProblem; path: string }
|
|
16
|
+
| { kind: 'permission'; problem: SkillIndexProblem; path: string; hint: string }
|
|
17
|
+
| { kind: 'manual'; problem: SkillIndexProblem; path: string; hint: string };
|
|
18
|
+
|
|
19
|
+
export function skillFilePath(problem: SkillIndexProblem): string {
|
|
20
|
+
return join(problem.dir, problem.name, 'SKILL.md');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function quote(value: string): string {
|
|
24
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripQuotes(value: string): string {
|
|
28
|
+
const v = value.trim();
|
|
29
|
+
return (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ? v.slice(1, -1) : v;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Rewrite list-valued scalar keys as one quoted line; returns null when nothing needs changing. */
|
|
33
|
+
export function flattenScalarLists(markdown: string): { text: string; keys: string[] } | null {
|
|
34
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
|
|
35
|
+
if (!fm) return null;
|
|
36
|
+
const lines = fm[1]!.split(/\r?\n/);
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
const keys: string[] = [];
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
const line = lines[i]!;
|
|
41
|
+
const m = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
|
|
42
|
+
if (!m || !SCALAR_KEYS.includes(m[1]!)) { out.push(line); continue; }
|
|
43
|
+
const key = m[1]!;
|
|
44
|
+
const value = m[2]!.trim();
|
|
45
|
+
if (/^\[.*\]$/.test(value)) {
|
|
46
|
+
const items = value.slice(1, -1).split(',').map(stripQuotes).filter(Boolean);
|
|
47
|
+
out.push(`${key}: ${quote(items.join(' · '))}`); keys.push(key);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (value === '') {
|
|
51
|
+
const items: string[] = [];
|
|
52
|
+
let j = i + 1;
|
|
53
|
+
while (j < lines.length && /^\s+-\s+/.test(lines[j]!)) { items.push(stripQuotes(lines[j]!.replace(/^\s+-\s+/, ''))); j++; }
|
|
54
|
+
if (items.length) { out.push(`${key}: ${quote(items.join(' · '))}`); keys.push(key); i = j - 1; continue; }
|
|
55
|
+
}
|
|
56
|
+
out.push(line);
|
|
57
|
+
}
|
|
58
|
+
if (!keys.length) return null;
|
|
59
|
+
const text = markdown.slice(0, fm.index) + `---\n${out.join('\n')}\n---` + markdown.slice(fm.index + fm[0].length);
|
|
60
|
+
return { text, keys };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function planSkillRepair(problem: SkillIndexProblem, read: (path: string) => string = (p) => readFileSync(p, 'utf8')): SkillRepairPlan {
|
|
64
|
+
const path = skillFilePath(problem);
|
|
65
|
+
if (problem.code === 'EACCES' || problem.code === 'EPERM') {
|
|
66
|
+
// Our own file: we may add the read bit after the person says yes. Someone else's: name the command only.
|
|
67
|
+
try {
|
|
68
|
+
if (typeof process.getuid === 'function' && statSync(path).uid === process.getuid()) return { kind: 'fixable-permission', problem, path };
|
|
69
|
+
} catch { /* fall through to the hint */ }
|
|
70
|
+
return { kind: 'permission', problem, path, hint: `읽기 권한이 없습니다 — 터미널에서: sudo chmod a+r "${path}"` };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const fixed = flattenScalarLists(read(path));
|
|
74
|
+
if (fixed) return { kind: 'fixable', problem, path, keys: fixed.keys };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
77
|
+
if (code === 'EACCES' || code === 'EPERM') return { kind: 'permission', problem, path, hint: `읽기 권한이 없습니다 — 터미널에서: chmod u+r "${path}"` };
|
|
78
|
+
}
|
|
79
|
+
return { kind: 'manual', problem, path, hint: `자동으로 고칠 수 없는 형식입니다 — 파일 맨 위 «---» 사이를 확인해 주세요: ${path}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type SkillRepairResult = { ok: true; path: string; keys: string[]; backup: string } | { ok: false; path: string; reason: string };
|
|
83
|
+
|
|
84
|
+
function stillUnreadable(problem: SkillIndexProblem): boolean {
|
|
85
|
+
buildSkillIndex([problem.dir]);
|
|
86
|
+
return skillIndexProblems().some((p) => p.dir === problem.dir && p.name === problem.name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Apply a fixable plan (after the person said yes): read bit, or back up → rewrite → re-index → restore on failure. */
|
|
90
|
+
export function applySkillRepair(plan: Extract<SkillRepairPlan, { kind: 'fixable' | 'fixable-permission' }>): SkillRepairResult {
|
|
91
|
+
const { path, problem } = plan;
|
|
92
|
+
if (plan.kind === 'fixable-permission') {
|
|
93
|
+
const before = statSync(path).mode & 0o777;
|
|
94
|
+
chmodSync(path, before | 0o400);
|
|
95
|
+
if (stillUnreadable(problem)) {
|
|
96
|
+
chmodSync(path, before);
|
|
97
|
+
debug.log('skills.repair', 'reverted', { name: problem.name, kind: 'permission' });
|
|
98
|
+
return { ok: false, path, reason: 'still-unreadable' };
|
|
99
|
+
}
|
|
100
|
+
debug.log('skills.repair', 'repaired', { name: problem.name, kind: 'permission' });
|
|
101
|
+
return { ok: true, path, keys: ['permission'], backup: '' };
|
|
102
|
+
}
|
|
103
|
+
const original = readFileSync(path, 'utf8');
|
|
104
|
+
const fixed = flattenScalarLists(original);
|
|
105
|
+
if (!fixed) return { ok: false, path, reason: 'nothing-to-fix' };
|
|
106
|
+
const backup = `${path}.bak`;
|
|
107
|
+
copyFileSync(path, existsSync(backup) ? `${path}.${Date.now()}.bak` : backup);
|
|
108
|
+
const temp = `${path}.tmp-${process.pid}`;
|
|
109
|
+
writeFileSync(temp, fixed.text);
|
|
110
|
+
renameSync(temp, path);
|
|
111
|
+
if (stillUnreadable(problem)) {
|
|
112
|
+
writeFileSync(path, original);
|
|
113
|
+
debug.log('skills.repair', 'reverted', { name: problem.name, keys: fixed.keys });
|
|
114
|
+
return { ok: false, path, reason: 'still-unreadable' };
|
|
115
|
+
}
|
|
116
|
+
debug.log('skills.repair', 'repaired', { name: problem.name, keys: fixed.keys });
|
|
117
|
+
return { ok: true, path, keys: fixed.keys, backup };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One line for a surface (TUI · REPL · PWA) — null when every skill was read. */
|
|
121
|
+
export function skillRepairNotice(problems: readonly SkillIndexProblem[]): string | null {
|
|
122
|
+
if (!problems.length) return null;
|
|
123
|
+
return `스킬 ${problems.length}개를 읽지 못했습니다 — 고치려면: elanous skills repair (확인한 뒤에만 고칩니다)`;
|
|
124
|
+
}
|
package/src/skills/runner.ts
CHANGED
|
@@ -330,6 +330,15 @@ function parseTier(raw: unknown): SkillTier | undefined {
|
|
|
330
330
|
|
|
331
331
|
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
|
|
332
332
|
|
|
333
|
+
/** A frontmatter value as one line of text. Third-party SKILL.md files (~/.codex/skills, ~/.claude/skills …)
|
|
334
|
+
* sometimes write `description:` as a YAML list; the parser then returns an array, and a 0.2.6 beta tester's
|
|
335
|
+
* first screen crashed on `description.trim is not a function`. Lists are joined; anything else non-string is dropped. */
|
|
336
|
+
export function frontmatterText(value: unknown): string {
|
|
337
|
+
if (typeof value === 'string') return value;
|
|
338
|
+
if (Array.isArray(value)) return value.filter((x): x is string => typeof x === 'string').join(' ').trim();
|
|
339
|
+
return '';
|
|
340
|
+
}
|
|
341
|
+
|
|
333
342
|
function parseFrontmatter(md: string): { fm: Record<string, any>; body: string } {
|
|
334
343
|
const match = md.match(FRONTMATTER_RE);
|
|
335
344
|
if (!match) return { fm: {}, body: md };
|
|
@@ -544,8 +553,8 @@ export function parseSkillMd(skillName: string, baseDir: string = LOCAL_SKILLS_D
|
|
|
544
553
|
const cost = rawCost === 'light' || rawCost === 'heavy' ? rawCost : undefined;
|
|
545
554
|
|
|
546
555
|
return {
|
|
547
|
-
name: fm['name'] || skillName,
|
|
548
|
-
description: fm['description']
|
|
556
|
+
name: frontmatterText(fm['name']) || skillName,
|
|
557
|
+
description: frontmatterText(fm['description']),
|
|
549
558
|
model: fm['model'] || undefined,
|
|
550
559
|
allowedTools,
|
|
551
560
|
deniedTools,
|
|
@@ -56,7 +56,7 @@ const BACKWARD_MARKER = /(?:등(?:의)?\s*)?(?:언급\s*)?시\s*(?:이?\s*스킬
|
|
|
56
56
|
* keywords. Empty array is a perfectly normal result (description
|
|
57
57
|
* without any recognized marker). */
|
|
58
58
|
export function extractTriggers(description: string): string[] {
|
|
59
|
-
if (
|
|
59
|
+
if (typeof description !== 'string' || !description.trim()) return [];
|
|
60
60
|
|
|
61
61
|
const segments = extractTriggerSegments(description);
|
|
62
62
|
const items: string[] = [];
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// codex·elanous 자격 파일은 이 Job 에 없다. 격리 관문(호스트 넥서스에 닿으면 종료)은 기존 Job 과 같다.
|
|
6
6
|
// 명령 인자는 셸 문자열로 이어붙이지 않고 `"$@"` 로 따로 인용한다.
|
|
7
7
|
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import { isAbsolute } from 'node:path';
|
|
10
11
|
import { getUserConfig } from '../../user-config.js';
|
|
@@ -66,6 +67,8 @@ if [ -d "$HOME/outbox" ]; then
|
|
|
66
67
|
fi`;
|
|
67
68
|
|
|
68
69
|
export interface PodCommandJobInput {
|
|
70
|
+
/** POD1 — this launch's own label; cleanup selects by it, never by name alone. */
|
|
71
|
+
launch?: string;
|
|
69
72
|
name: string;
|
|
70
73
|
namespace: string;
|
|
71
74
|
image: string;
|
|
@@ -122,13 +125,13 @@ export function podCommandJobManifest(o: PodCommandJobInput): Record<string, unk
|
|
|
122
125
|
return {
|
|
123
126
|
apiVersion: 'batch/v1',
|
|
124
127
|
kind: 'Job',
|
|
125
|
-
metadata: { name: o.name, namespace: o.namespace, labels: { 'elanous.substrate': 'pod', 'elanous.job': o.name, 'elanous.kind': 'command' } },
|
|
128
|
+
metadata: { name: o.name, namespace: o.namespace, labels: { 'elanous.substrate': 'pod', 'elanous.job': o.name, 'elanous.kind': 'command', ...(o.launch ? { 'elanous.launch': o.launch } : {}) } },
|
|
126
129
|
spec: {
|
|
127
130
|
backoffLimit: 0,
|
|
128
131
|
ttlSecondsAfterFinished: 7200,
|
|
129
132
|
activeDeadlineSeconds: o.deadlineSeconds,
|
|
130
133
|
template: {
|
|
131
|
-
metadata: { labels: { 'elanous.job': o.name, 'elanous.kind': 'command' } },
|
|
134
|
+
metadata: { labels: { 'elanous.job': o.name, 'elanous.kind': 'command', ...(o.launch ? { 'elanous.launch': o.launch } : {}) } },
|
|
132
135
|
spec: {
|
|
133
136
|
restartPolicy: 'Never',
|
|
134
137
|
securityContext: { runAsUser: 1000, fsGroup: 1000 },
|
|
@@ -166,6 +169,7 @@ export function commandJobSecret(o: {
|
|
|
166
169
|
skillEnvText: Readonly<Record<string, string>>;
|
|
167
170
|
grokAuth?: string;
|
|
168
171
|
ghToken?: string;
|
|
172
|
+
launch?: string;
|
|
169
173
|
}): Record<string, unknown> | null {
|
|
170
174
|
const stringData: Record<string, string> = {};
|
|
171
175
|
if (o.grokAuth !== undefined) stringData['grok-auth.json'] = o.grokAuth;
|
|
@@ -177,7 +181,7 @@ export function commandJobSecret(o: {
|
|
|
177
181
|
if (Object.keys(stringData).length === 0) return null;
|
|
178
182
|
return {
|
|
179
183
|
apiVersion: 'v1', kind: 'Secret', type: 'Opaque',
|
|
180
|
-
metadata: { name: `${o.name}-creds`, namespace: o.namespace, labels: { 'elanous.job': o.name } },
|
|
184
|
+
metadata: { name: `${o.name}-creds`, namespace: o.namespace, labels: { 'elanous.job': o.name, ...(o.launch ? { 'elanous.launch': o.launch } : {}) } },
|
|
181
185
|
stringData,
|
|
182
186
|
};
|
|
183
187
|
}
|
|
@@ -265,7 +269,11 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
|
|
|
265
269
|
const image = options.image ?? 'elanous-harness:local';
|
|
266
270
|
const repoUrl = options.repoUrl ?? 'https://github.com/ElanvitalAI/elanous';
|
|
267
271
|
const deadlineSeconds = options.deadlineSeconds ?? POD_COMMAND_DEADLINE_SECONDS;
|
|
268
|
-
|
|
272
|
+
// POD1 (10-01): two launches in the same moment got the same `si-cmd-<time>` and the failing one's cleanup
|
|
273
|
+
// deleted the other's credentials. The name now has a random tail, and cleanup selects this launch's label.
|
|
274
|
+
const launch = randomBytes(6).toString('hex');
|
|
275
|
+
const name = options.name ?? podJobName(`cmd-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`);
|
|
276
|
+
const own = `elanous.job=${name},elanous.launch=${launch}`;
|
|
269
277
|
const env = options.env ?? process.env;
|
|
270
278
|
const hostMirror = (options.hostMirror ?? env.ELANOUS_POD_HOST_MIRROR
|
|
271
279
|
?? (options.configHostMirror ?? (() => getUserConfig().pod?.hostMirror))())?.trim() || undefined;
|
|
@@ -293,7 +301,7 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
|
|
|
293
301
|
const currentContext = member || options.kubectl ? null : baseKubectl(['config', 'current-context']);
|
|
294
302
|
const context = member?.context ?? (currentContext?.status === 0 ? currentContext.stdout.trim() : '');
|
|
295
303
|
const kubectl: Kubectl = (args, stdin) => baseKubectl(context ? ['--context', context, ...args] : [...args], stdin);
|
|
296
|
-
const cleanupSecret = () => { kubectl(['-n', namespace, 'delete', 'secret',
|
|
304
|
+
const cleanupSecret = () => { kubectl(['-n', namespace, 'delete', 'secret', '-l', own, '--ignore-not-found']); };
|
|
297
305
|
const artifactsDir = `${options.artifactsRoot ?? `${effectiveInstanceRoot()}/pod-artifacts`}/${name}`;
|
|
298
306
|
try {
|
|
299
307
|
if (!context && !options.kubectl) throw new Error('pod command: kubectl context 를 확인할 수 없다');
|
|
@@ -328,19 +336,19 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
|
|
|
328
336
|
if (synced?.imageRef) member = { ...member, imageRef: synced.imageRef };
|
|
329
337
|
}
|
|
330
338
|
const ghToken = options.clone ? (options.ghToken ?? defaultGhToken)() : undefined;
|
|
331
|
-
const secret = commandJobSecret({ name, namespace, skills, skillEnvText, ...(grokAuth !== undefined ? { grokAuth } : {}), ...(ghToken !== undefined ? { ghToken } : {}) });
|
|
339
|
+
const secret = commandJobSecret({ name, namespace, launch, skills, skillEnvText, ...(grokAuth !== undefined ? { grokAuth } : {}), ...(ghToken !== undefined ? { ghToken } : {}) });
|
|
332
340
|
if (secret) {
|
|
333
341
|
const applied = kubectl(['apply', '-f', '-'], JSON.stringify(secret));
|
|
334
342
|
if (applied.status !== 0) throw new Error(applied.stderr.trim() || 'pod command: Secret 적용 실패');
|
|
335
343
|
}
|
|
336
344
|
const manifest = podCommandJobManifest({
|
|
337
|
-
name, namespace, image: member?.imageRef ?? image,
|
|
345
|
+
name, namespace, launch, image: member?.imageRef ?? image,
|
|
338
346
|
...(member?.imageRef ? { imagePullPolicy: 'IfNotPresent' as const } : {}),
|
|
339
347
|
repoUrl, command, skills, ...(llm ? { llm } : {}), ...(options.clone ? { clone: true } : {}),
|
|
340
348
|
...(options.source ? { source: options.source } : {}), ...(hostMirror ? { hostMirror } : {}), ...(bunCache ? { bunCache } : {}), ...(options.memoryLimit ? { memoryLimit: options.memoryLimit } : {}), deadlineSeconds,
|
|
341
349
|
...(options.runId ? { runId: options.runId } : {}),
|
|
342
350
|
});
|
|
343
|
-
kubectl(['-n', namespace, 'delete', 'job',
|
|
351
|
+
kubectl(['-n', namespace, 'delete', 'job', '-l', own, '--ignore-not-found']);
|
|
344
352
|
const applied = kubectl(['apply', '-f', '-'], JSON.stringify(manifest));
|
|
345
353
|
if (applied.status !== 0) {
|
|
346
354
|
cleanupSecret();
|
package/src/telegram-commands.ts
CHANGED
|
@@ -762,6 +762,19 @@ export function defaultTelegramCommands(): TgSlashCommand[] {
|
|
|
762
762
|
});
|
|
763
763
|
},
|
|
764
764
|
},
|
|
765
|
+
{
|
|
766
|
+
name: 'decisions',
|
|
767
|
+
description: '열린 결정 목록 — 카드를 다시 보내 여기서 바로 정할 수 있습니다',
|
|
768
|
+
handler: async (_args, ctx) => {
|
|
769
|
+
const { telegramDecisionService } = await import('./decisions/telegram-decision-cards.js');
|
|
770
|
+
const service = telegramDecisionService();
|
|
771
|
+
if (!service) return '결정 카드가 이 봇에 연결되어 있지 않습니다.';
|
|
772
|
+
if (!service.isOwner(String(ctx.userId))) return '결정은 소유자만 볼 수 있습니다.';
|
|
773
|
+
// A group would show every member the cards (and route memos from them) — private chat only (TC review #22660).
|
|
774
|
+
if (ctx.chatId !== ctx.userId) return '결정은 개인 대화에서만 볼 수 있습니다.';
|
|
775
|
+
return service.listOpen(String(ctx.chatId));
|
|
776
|
+
},
|
|
777
|
+
},
|
|
765
778
|
{
|
|
766
779
|
name: 'work',
|
|
767
780
|
description: '글을 알맞은 곳(흡수·태스크·하니스)에 넣고 결과를 여기로 답합니다 — /work [absorb|tasks|graph] <글>',
|
package/src/telegram.ts
CHANGED
|
@@ -919,6 +919,24 @@ export class TelegramBot {
|
|
|
919
919
|
return undefined;
|
|
920
920
|
}
|
|
921
921
|
|
|
922
|
+
/** DEC-TG — replace a message's text AND its inline keyboard (empty rows remove the buttons). */
|
|
923
|
+
async editMessageWithKeyboard(
|
|
924
|
+
chatId: number,
|
|
925
|
+
messageId: number,
|
|
926
|
+
text: string,
|
|
927
|
+
buttons: Array<Array<{ text: string; data: string }>>,
|
|
928
|
+
): Promise<void> {
|
|
929
|
+
await this.throttledCall<unknown>(chatId, 'editMessageText', {
|
|
930
|
+
chat_id: chatId,
|
|
931
|
+
message_id: messageId,
|
|
932
|
+
text: text.length > this.maxChars ? `${text.slice(0, this.maxChars - 2)} …` : text,
|
|
933
|
+
reply_markup: { inline_keyboard: buttons.map((row) => row.map((b) => ({ text: b.text, callback_data: b.data.slice(0, 64) }))) },
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** True while the long-poll loop runs — only a polling bot should push decision cards (nexus may hold a send-only bot). */
|
|
938
|
+
isPolling(): boolean { return this.running; }
|
|
939
|
+
|
|
922
940
|
/** T2-P6 — acknowledge a callback_query so Telegram clears the
|
|
923
941
|
* spinner next to the button the user tapped. Pass a `text` to
|
|
924
942
|
* flash a tiny toast on the user's device (≤200 chars). Returns
|
|
@@ -1156,6 +1174,8 @@ export class TelegramBot {
|
|
|
1156
1174
|
if (u.callback_query) {
|
|
1157
1175
|
const cq = u.callback_query;
|
|
1158
1176
|
if (!this.isOwnerAllowed(cq.from.id)) {
|
|
1177
|
+
// DEC-TG — a decision-card tap from someone else is the rehearsal's «refused» case; name it there too.
|
|
1178
|
+
if ((cq.data ?? '').startsWith('dec:')) debug.log('decisions.telegram', 'tap-refused', { platform: 'telegram', gate: 'owner-allowlist' }, { level: 'warn' });
|
|
1159
1179
|
debug.log('telegram.owner-gate', 'refused', {
|
|
1160
1180
|
kind: 'callback', reason: this.allowedUsers.size === 0 ? 'empty-allowlist' : 'not-allowed', userId: cq.from.id,
|
|
1161
1181
|
});
|
|
@@ -1425,6 +1445,9 @@ export class TelegramBot {
|
|
|
1425
1445
|
...reply, mimeType: 'video/mp4', caption: `현장 영상 · ${event} · ${result.seconds}초`,
|
|
1426
1446
|
});
|
|
1427
1447
|
if (!sent) throw new Error('sendDocument failed');
|
|
1448
|
+
if (result.feed?.started) {
|
|
1449
|
+
await this.sendMessage(ctx.chatId, `인스타 피드 초안을 만드는 중 · ${event} — 1분쯤 뒤 앱 «게시 대기»에서 고치고 «최종 게시»를 누르면 됩니다(누르기 전엔 아무 데도 안 올라갑니다).`, reply);
|
|
1450
|
+
}
|
|
1428
1451
|
} else {
|
|
1429
1452
|
await this.sendMessage(ctx.chatId, `현장 영상 실패 · ${event} · ${result.error ?? 'render failed'}`, reply);
|
|
1430
1453
|
}
|
|
@@ -2840,6 +2863,17 @@ export function botFromConfig(opts: BotFromConfigOpts): TelegramBot {
|
|
|
2840
2863
|
// poll loop requests `callback_query` from the first getUpdates —
|
|
2841
2864
|
// in-turn approval taps then arrive without a mid-turn delay.
|
|
2842
2865
|
botRef.ensureSurfaceHitl();
|
|
2866
|
+
// DEC-TG(대표 10-02) — decision cards on the MAIN bot only (other channel pollers carry other tokens). The ticker
|
|
2867
|
+
// itself sends only while this bot polls, so a send-only nexus bot never duplicates the standalone poller's cards.
|
|
2868
|
+
if (opts.userConfig.raw?.decisions === undefined || (opts.userConfig.raw.decisions as { cards?: unknown }).cards !== false) {
|
|
2869
|
+
const mainToken = resolveChannelBotToken('telegram', opts.userConfig)?.token;
|
|
2870
|
+
if (mainToken && botToken === mainToken) {
|
|
2871
|
+
const bot = botRef;
|
|
2872
|
+
void import('./decisions/telegram-decision-cards.js')
|
|
2873
|
+
.then(({ attachTelegramDecisionCards }) => { attachTelegramDecisionCards(bot, opts.userConfig); })
|
|
2874
|
+
.catch((err: unknown) => log(`[telegram] decision cards not attached: ${err instanceof Error ? err.message : String(err)}`));
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2843
2877
|
return botRef;
|
|
2844
2878
|
}
|
|
2845
2879
|
|