From 4c2b1b634c260bd4748e504396c74d369fb12707 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Mon, 6 Apr 2026 18:37:54 +0800 Subject: [PATCH] feat: release ZIP keeps scripts/ tree; JIANGCHANG_SKILLS_ROOT and sibling skills root --- .github/workflows/reusable-release-skill.yaml | 6 +- sdk/jiangchang_skill_core/__init__.py | 30 ++++ sdk/jiangchang_skill_core/runtime_env.py | 127 ++++++++++++++ sdk/jiangchang_skill_core/unified_logging.py | 158 ++++++++++++++++++ tools/release.ps1 | 2 +- 5 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 sdk/jiangchang_skill_core/runtime_env.py create mode 100644 sdk/jiangchang_skill_core/unified_logging.py diff --git a/.github/workflows/reusable-release-skill.yaml b/.github/workflows/reusable-release-skill.yaml index a231394..9f3d88d 100644 --- a/.github/workflows/reusable-release-skill.yaml +++ b/.github/workflows/reusable-release-skill.yaml @@ -58,13 +58,13 @@ jobs: python -c "import os,base64,pathlib,subprocess; p=pathlib.Path('/tmp/pyarmor-reg.zip'); p.write_bytes(base64.standard_b64decode(os.environ['PYARMOR_REG_B64'])); subprocess.run(['pyarmor','reg',str(p)],check=True); p.unlink(missing_ok=True)" fi - # 递归加密整个 scripts/(含 cli、service、db、util 等子包);仅 scripts/*.py 会漏模块导致运行时 ModuleNotFoundError。 + # 递归加密整个 scripts/(含 cli、service、db、util 等子包);产物保留与源码一致的 scripts/ 层级,入口为 scripts/main.py。 - name: Encrypt Source Code run: | - mkdir -p dist/package + mkdir -p dist/package/scripts set -euo pipefail test -d scripts - ( cd scripts && pyarmor gen --platform "${PYARMOR_PLATFORM}" -r -O ../dist/package . ) + ( cd scripts && pyarmor gen --platform "${PYARMOR_PLATFORM}" -r -O ../dist/package/scripts . ) cp SKILL.md dist/package/ if [ -d references ]; then cp -r references dist/package/; fi if [ -d assets ]; then cp -r assets dist/package/; fi diff --git a/sdk/jiangchang_skill_core/__init__.py b/sdk/jiangchang_skill_core/__init__.py index c49320b..1d0c299 100644 --- a/sdk/jiangchang_skill_core/__init__.py +++ b/sdk/jiangchang_skill_core/__init__.py @@ -1,9 +1,39 @@ from .client import EntitlementClient from .guard import enforce_entitlement from .models import EntitlementResult +from .runtime_env import ( + apply_cli_local_defaults, + get_data_root, + get_sibling_skills_root, + get_skills_root, + get_user_id, + platform_default_data_root, +) +from .unified_logging import ( + attach_unified_file_handler, + ensure_trace_for_process, + get_skill_log_file_path, + get_skill_logger, + get_unified_logs_dir, + setup_skill_logging, + subprocess_env_with_trace, +) __all__ = [ "EntitlementClient", "EntitlementResult", + "apply_cli_local_defaults", + "attach_unified_file_handler", "enforce_entitlement", + "ensure_trace_for_process", + "get_data_root", + "get_sibling_skills_root", + "get_skills_root", + "get_skill_log_file_path", + "get_skill_logger", + "get_unified_logs_dir", + "get_user_id", + "platform_default_data_root", + "setup_skill_logging", + "subprocess_env_with_trace", ] diff --git a/sdk/jiangchang_skill_core/runtime_env.py b/sdk/jiangchang_skill_core/runtime_env.py new file mode 100644 index 0000000..129be8d --- /dev/null +++ b/sdk/jiangchang_skill_core/runtime_env.py @@ -0,0 +1,127 @@ +""" +JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。 + +各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。 +""" + +from __future__ import annotations + +import os +import sys + +# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启 +CLI_LOCAL_DEV_ENABLED = True + +# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改) +DEFAULT_LOCAL_USER_ID = "10032" + +# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径 +WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data" + +# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列) +WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang" + + +def platform_default_data_root() -> str: + if sys.platform == "win32": + return WIN_DEFAULT_DATA_ROOT + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + +def get_data_root() -> str: + env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + if env: + return env + return platform_default_data_root() + + +def get_user_id() -> str: + return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" + + +def _looks_like_skills_root(path: str) -> bool: + if not path or not os.path.isdir(path): + return False + for marker in ( + "llm-manager", + "content-manager", + "account-manager", + "sohu-publisher", + "api-key-vault", + ): + if os.path.isdir(os.path.join(path, marker)): + return True + return False + + +def get_skills_root() -> str: + """ + 并列技能安装目录(其下为「技能 slug」子目录)。 + + 优先级: + 1) JIANGCHANG_SKILLS_ROOT + 2) CLAW_SKILLS_ROOT + 3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根 + 4) Windows:默认 D:\\AI\\jiangchang 同上规则 + 5) 其他平台:~/.openclaw/skills + """ + for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): + v = (os.getenv(key) or "").strip() + if v: + return os.path.normpath(v) + + app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip() + if sys.platform == "win32" and not app: + app = WIN_DEFAULT_JIANGCHANG_APP_ROOT + if app: + nested = os.path.join(app, "skills") + if _looks_like_skills_root(nested): + return os.path.normpath(nested) + if _looks_like_skills_root(app): + return os.path.normpath(app) + + if sys.platform == "win32": + nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills") + if _looks_like_skills_root(nested): + return os.path.normpath(nested) + if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT): + return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT) + + return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills")) + + +def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str: + """ + 编排子进程调用兄弟技能时用:优先环境变量;否则从本技能 scripts 目录推断并列根 + (OpenClaw 开发仓与网关 skills//scripts 均满足「技能根之上一级 = 并列根」)。 + """ + for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): + v = (os.getenv(key) or "").strip() + if v: + return os.path.normpath(v) + + if skill_scripts_dir: + scripts = os.path.abspath(skill_scripts_dir) + skill_root = os.path.dirname(scripts) + inferred = os.path.dirname(skill_root) + if _looks_like_skills_root(inferred): + return inferred + + return get_skills_root() + + +def apply_cli_local_defaults() -> None: + """ + 在 CLI 最早阶段调用(main.py 在 import 业务包之前)。 + 宿主已设置 JIANGCHANG_* 时不会覆盖。 + """ + enabled = CLI_LOCAL_DEV_ENABLED + if not enabled: + v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower() + enabled = v in ("1", "true", "yes", "on") + if not enabled: + return + if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip(): + os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root() + if not (os.getenv("JIANGCHANG_USER_ID") or "").strip(): + os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip() diff --git a/sdk/jiangchang_skill_core/unified_logging.py b/sdk/jiangchang_skill_core/unified_logging.py new file mode 100644 index 0000000..ea90eb3 --- /dev/null +++ b/sdk/jiangchang_skill_core/unified_logging.py @@ -0,0 +1,158 @@ +""" +统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。 + +实现为各技能 scripts/jiangchang_skill_core/ 下的同名副本之源,修改后请同步到各技能。 +""" + +from __future__ import annotations + +import logging +import os +import sys +import uuid +from logging.handlers import TimedRotatingFileHandler +from typing import Optional + +from .runtime_env import get_data_root, get_user_id + +_skill_slug: str = "" +_logger_name: str = "" + + +def get_unified_logs_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), "logs") + os.makedirs(path, exist_ok=True) + return path + + +def get_skill_log_file_path() -> str: + """与 setup 写入的主日志文件一致(供终端提示等)。""" + override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip() + if override: + parent = os.path.dirname(os.path.abspath(override)) + if parent: + os.makedirs(parent, exist_ok=True) + return os.path.abspath(override) + return os.path.join(get_unified_logs_dir(), "jiangchang.log") + + +def ensure_trace_for_process() -> str: + """本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。""" + existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() + if existing: + return existing + tid = uuid.uuid4().hex[:12] + os.environ["JIANGCHANG_TRACE_ID"] = tid + return tid + + +def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict: + """subprocess.run(..., env=...) 时使用,保证带上当前 trace。""" + ensure_trace_for_process() + tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() + if not tid: + tid = ensure_trace_for_process() + base = os.environ if environ is None else environ + return {**base, "JIANGCHANG_TRACE_ID": tid} + + +class _SkillContextFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-" + record.skill_slug = _skill_slug or "-" + return True + + +_FORMAT = ( + "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s" +) +_DATEFMT = "%Y-%m-%dT%H:%M:%S" + + +def _log_level_from_env() -> int: + v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper() + return getattr(logging, v, None) or logging.INFO + + +def _backup_count() -> int: + try: + n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip()) + return max(1, min(n, 365)) + except ValueError: + return 30 + + +def setup_skill_logging(skill_slug: str, logger_name: str) -> None: + """ + 幂等:为指定 logger 挂载统一文件日志与可选 stderr。 + 须在进程早期调用(如 CLI main 在业务日志之前)。 + """ + global _skill_slug, _logger_name + ensure_trace_for_process() + _skill_slug = skill_slug + _logger_name = logger_name + + log = logging.getLogger(logger_name) + if log.handlers: + return + log.setLevel(_log_level_from_env()) + path = get_skill_log_file_path() + fh = TimedRotatingFileHandler( + path, + when="midnight", + interval=1, + backupCount=_backup_count(), + encoding="utf-8", + delay=True, + ) + fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + fh.setFormatter(fmt) + fh.addFilter(_SkillContextFilter()) + log.addHandler(fh) + if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ): + sh = logging.StreamHandler(sys.stderr) + sh.setLevel(logging.WARNING) + sh.setFormatter(fmt) + sh.addFilter(_SkillContextFilter()) + log.addHandler(sh) + log.propagate = False + + +def get_skill_logger() -> logging.Logger: + if not _logger_name: + raise RuntimeError("get_skill_logger: call setup_skill_logging first") + return logging.getLogger(_logger_name) + + +def attach_unified_file_handler( + log_path: str, + *, + skill_slug: str, + logger_name: str, + level: int = logging.DEBUG, +) -> logging.Logger: + """ + 独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。 + """ + global _skill_slug + ensure_trace_for_process() + _skill_slug = skill_slug + lg = logging.getLogger(logger_name) + lg.handlers.clear() + lg.setLevel(level) + parent = os.path.dirname(os.path.abspath(log_path)) + if parent: + os.makedirs(parent, exist_ok=True) + fh = logging.FileHandler(log_path, encoding="utf-8") + fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + fh.setFormatter(fmt) + fh.addFilter(_SkillContextFilter()) + lg.addHandler(fh) + lg.propagate = False + return lg diff --git a/tools/release.ps1 b/tools/release.ps1 index d0bb806..a11088c 100644 --- a/tools/release.ps1 +++ b/tools/release.ps1 @@ -26,7 +26,7 @@ Requires: git, PowerShell 5+ 加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行: - 对 scripts/ 递归 PyArmor(-r),并复制 SKILL.md、references/、assets/(若存在)。 + 对 scripts/ 递归 PyArmor(-r),输出到包内 scripts/(与源码目录树一致),并复制 SKILL.md、references/、assets/(若存在)。 本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。 #>