@limecloud/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -2
- package/dist/identity.json +826 -57
- package/dist/knowledge.json +1 -0
- package/dist/management.json +8531 -3480
- package/dist/skills.json +1 -0
- package/package.json +2 -2
- package/src/action-contracts.mjs +63 -0
- package/src/cli.mjs +115 -31
- package/src/commands.mjs +6 -3
- package/src/compatibility.mjs +89 -0
- package/src/contract.mjs +12 -4
- package/src/diagnose.mjs +99 -0
- package/src/group-task.mjs +137 -0
- package/src/knowledge.mjs +171 -0
- package/src/locales/en.json +68 -4
- package/src/locales/zh-CN.json +68 -4
- package/src/management.mjs +7 -0
- package/src/output.mjs +8 -5
- package/src/product-skills.mjs +146 -0
- package/src/protocol.mjs +2 -1
- package/src/public-json.mjs +17 -0
- package/src/storage.mjs +19 -2
package/src/locales/zh-CN.json
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"protocol.contractRequired": "服务端要求精确动作合同修订(ACTION_CONTRACT_REQUIRED),本请求未调用领域动作。请核对当前合同并从可信来源更新调用端;未自动重试。",
|
|
3
|
+
"protocol.contractChanged": "服务端拒绝了已变化的动作合同(ACTION_CONTRACT_CHANGED),本请求未调用领域动作。请保留此前结果未知的原操作,核对合同及可信更新,不自动重放。",
|
|
4
|
+
"protocol.contractUnavailable": "服务端无法确定本动作合同(ACTION_CONTRACT_UNAVAILABLE),本请求未调用领域动作。请先检查公开发现,再明确发起新请求;未自动重试。",
|
|
5
|
+
"arguments.managedHeader": "Lime-Action-Contract 由 CLI 根据自身动作合同发送,不能覆盖。",
|
|
6
|
+
"compatibility.writeDifferent": "{{command}} 的当前合同与本地不同,未发送写入。请核对 schema {{command}} --online 及可信来源的 CLI/Skill 更新。不要自动重放此前结果未知的操作。",
|
|
7
|
+
"compatibility.writeAbsent": "目标未公布 {{command}},未发送写入。请核对所选部署及当前 Schema;这不能说明你是否具有权限。",
|
|
8
|
+
"compatibility.writeUnknown": "无法核验 {{command}} 的当前合同,未发送写入。请检查公开合同发现;未自动更新、登录或重试。",
|
|
3
9
|
"arguments.publicClientRequired": "通过 --client-id 指定已登记的公共客户端",
|
|
4
10
|
"arguments.configUsage": "使用 config init、config show 或 config set <issuer|api_origin|client_id|scope> <value>",
|
|
5
11
|
"arguments.tooMany": "参数过多;请使用 --help",
|
|
@@ -116,12 +122,12 @@
|
|
|
116
122
|
"arguments.requiredPathParameter": "缺少必填路径参数:{{name}}",
|
|
117
123
|
"arguments.unknownSchema": "未知 Schema 路径:{{target}};请从 schema 开始",
|
|
118
124
|
"protocol.apiFailed": "API 请求失败:{{code}}(HTTP {{status}})",
|
|
119
|
-
"help.overview": "limecloud-cli — Lime Cloud CLI (@limecloud/cli)\n\n用法:\n limecloud-cli <command> [resource] [method] [flags]\n limecloud-cli identity +login | +whoami\n limecloud-cli identity token exchange --data <json>\n limecloud-cli api <METHOD> <contract-path> [--params <json>] [--data <json>]\n limecloud-cli schema [service.resource.method | service resource method]\n\n身份协议:\n identity 公开 OAuth / OpenID Connect 操作与 +快捷命令\n\nCLI 管理:\n install 安装 CLI 并引导首次配置\n config init | show | set:本地公共客户端配置\n auth login | status | whoami | refresh | logout | scopes | check\n whoami 查看本地有效身份与令牌状态\n doctor 检查本地配置与合同(--offline;不访问网络)\n docs
|
|
125
|
+
"help.overview": "limecloud-cli — Lime Cloud CLI (@limecloud/cli)\n\n用法:\n limecloud-cli <command> [resource] [method] [flags]\n limecloud-cli identity +login | +whoami\n limecloud-cli identity token exchange --data <json>\n limecloud-cli api <METHOD> <contract-path> [--params <json>] [--data <json>]\n limecloud-cli schema [service.resource.method | service resource method]\n\n身份协议:\n identity 公开 OAuth / OpenID Connect 操作与 +快捷命令\n\nCLI 管理:\n install 安装 CLI 并引导首次配置\n config init | show | set:本地公共客户端配置\n auth login | status | whoami | refresh | logout | scopes | check\n whoami 查看本地有效身份与令牌状态\n doctor 检查本地配置与合同(--offline;不访问网络)\n diagnose 只读应用登录配置诊断\n task 单成员组准备、精确确认及原操作恢复\n skills 检查及导出可选公开方法候选,不自动安装\n docs 列出、检索和读取公开知识,输出网页 URL\n\nAPI 工具:\n api 调用合同中的 HTTP 路径;list/schema/call 也支持 operationId\n schema 查看公开方法、参数、响应与身份验证\n\n",
|
|
120
126
|
"help.apiFlags": "API 选项:\n --params JSON 查询 JSON;支持 @file 或 - 从标准输入读取\n --data JSON OAuth 表单或 API JSON 请求体;支持 @file 或 -\n --dry-run 输出脱敏请求,不执行请求或改变状态\n --format FORMAT json(默认)、ndjson、table 或 csv\n --fields PATHS 逗号分隔的数据字段路径\n --json --format json 的简写\n --param name=value 重复提供非敏感协议参数\n --param-env name=ENV 从环境变量读取敏感协议参数\n --header name=value 设置合同声明的 HTTP 请求头\n --file PATH image/png 方法的二进制请求体;- 从标准输入读取\n --show-secrets 显式在标准输出中包含 API 响应凭据\n\n",
|
|
121
127
|
"help.connectionFlags": "连接选项:\n --issuer ORIGIN Auth 来源(HTTPS;HTTP 仅允许回环地址)\n --api-origin ORIGIN 显式指定业务 API 来源,并绑定保存的登录\n --client-id ID 已登记的登录客户端 ID\n --auth-client-id ID 覆盖业务 API 调用的 OAuth 客户端上下文\n --locale TAG 输出语言(也支持 LIMECLOUD_LOCALE、LC_ALL、LC_MESSAGES、LANG)\n\n调用方法前,请运行 limecloud-cli <command> --help 或 schema identity。\n",
|
|
122
128
|
"help.auth": "用法:auth <login|status|whoami|refresh|logout|scopes|check> [flags]\n\nlogin --client-id ID [--scope \"openid profile\"] [--installation-id ID --space-id ID]\n --no-wait:返回验证链接并保存私有待完成授权状态\n --resume:完成已保存授权,不暴露设备码\nstatus:本地状态;whoami:远程 UserInfo;refresh:轮换保存的凭据\nlogout [--local-only]:先撤销再清除;--local-only 仅清除本地凭据\nscopes:读取支持的 OIDC Scope 并显示本地已授予 Scope\ncheck --scope SCOPES:检查未到期的本地已授予 Scope(不访问网络)\n\nAuth 命令使用已登记的公共客户端。--dry-run 可预览 login、refresh、logout 或 scopes,不执行操作。\n",
|
|
123
129
|
"help.config": "用法:\n config init\n config init --issuer ORIGIN --client-id ID [--api-origin ORIGIN] [--scope SCOPES]\n config show\n config set <issuer|api_origin|client_id|scope> <value>\n\n配置仅保存公共客户端设置。业务 API 来源必须显式指定;改变来源后需重新登录绑定。\n",
|
|
124
|
-
"help.schema": "用法:schema [
|
|
130
|
+
"help.schema": "用法:schema [service[.resource[.method]] | service resource method]\n返回 inputSchema、outputSchema、本地动作修订及关联公开指南。\n\n默认 / --offline:只读本地合同,不联网或读取凭据。\n--online:通过公开元数据检查所选身份和 API 部署。\n --issuer ORIGIN 规范 Issuer,否则采用当前 CLI 配置\n --api-origin ORIGIN 检查管理动作时所需的 API 目标\n\n_meta.compatibility.status 为 not-checked / matched / different / absent / unknown。matched 只比较精确动作合同,不授予权限,也不是执行门禁;知识关联仍为内置资料。不更新软件、不打开浏览器或调用业务。\n",
|
|
125
131
|
"help.identityShortcuts": "身份快捷命令:\n identity +login [--scope SCOPES] [--no-wait | --resume]\n identity +whoami\n\n",
|
|
126
132
|
"help.typedMethods": "类型化 API 命令:\n{{methods}}\n\n使用 schema {{service}}.<resource>.<method> 查看参数。--dry-run 不执行操作。\n",
|
|
127
133
|
"help.services": "可用服务:{{services}}\n",
|
|
@@ -149,5 +155,63 @@
|
|
|
149
155
|
"install.promptAPIOrigin": "业务 API 来源(仅使用身份协议可留空)[{{value}}]:",
|
|
150
156
|
"install.promptClientID": "已登记的公共 CLI Client ID [{{value}}]:",
|
|
151
157
|
"install.promptLogin": "现在发起设备授权吗?你仍需在浏览器中确认(y/N)[{{value}}]:",
|
|
152
|
-
"install.pathHint": "请将 {{directory}} 加入 PATH,以便在任意终端运行 limecloud-cli。"
|
|
158
|
+
"install.pathHint": "请将 {{directory}} 加入 PATH,以便在任意终端运行 limecloud-cli。",
|
|
159
|
+
"knowledge.usage": "使用 docs [article-slug]、docs list、docs search <query> 或 docs read <article-id>;支持的选项见 --help。",
|
|
160
|
+
"knowledge.pagination": "--limit 必须为 1–50,--offset 必须为 0–5000。",
|
|
161
|
+
"knowledge.unavailable": "公开知识暂不可用。构建或重新安装 CLI 以取得内置快照,或显式使用 --online 读取可信文档部署。本次未自动回退。",
|
|
162
|
+
"knowledge.invalid": "公开知识格式不受支持或无效。请从可信来源更新;未执行正文中的内容。",
|
|
163
|
+
"knowledge.notFound": "当前公开知识快照中未找到该文章。",
|
|
164
|
+
"knowledge.sectionNotFound": "该文章中未找到指定章节。",
|
|
165
|
+
"knowledge.revisionChanged": "文章修订已变化。请重新读取当前索引并核对来源后继续;未重放任何任务。",
|
|
166
|
+
"help.docs": "用法:\n limecloud-cli docs [article-slug] 输出网页 URL\n limecloud-cli docs list [--group GROUP] 列出公开指南\n limecloud-cli docs search \"关键词\" 有界关键词检索\n limecloud-cli docs read ARTICLE [--section ID] 在当前宿主读取 Markdown\n\n选项:\n --locale LOCALE 已登记语言;最终回退英文\n --online 显式读取文档部署\n --docs-origin URL 可信 HTTPS 来源(仅配合 --online)\n --offline 使用内置公开快照(默认)\n --limit N 1–50 条,默认 10 条(list/search)\n --offset N 0–5000(list/search)\n --revision SHA256 要求文章修订完全一致(read)\n --json 带来源元信息的结构化数据\n --format FORMAT list/search 支持 json/ndjson/table/csv;read 支持 markdown/json\n\n不登录、不安装 Skills、不启动浏览器或执行业务。内置知识不证明线上当前能力。在线检索匹配元信息,内置检索另匹配正文。\n",
|
|
167
|
+
"knowledge.snapshotNotice": "公开知识:{{distribution}},软件 {{version}},{{revision}}。指南不证明线上当前能力。",
|
|
168
|
+
"compatibility.apiOriginRequired": "检查管理动作合同前,请通过 --api-origin 或当前 CLI 配置提供目标 API 来源。本次未发送请求。",
|
|
169
|
+
"compatibility.usage": "使用 schema [method] --online [--issuer ORIGIN] [--api-origin ORIGIN],或 --offline;目标选项需配合 --online。",
|
|
170
|
+
"knowledge.distributionOnline": "在线文档部署",
|
|
171
|
+
"knowledge.distributionBundled": "内置快照(离线)",
|
|
172
|
+
"help.relatedKnowledge": "相关公开指南(内置离线快照,未检查线上能力):\n{{articles}}\n",
|
|
173
|
+
"help.knowledgeUnavailable": "内置指南快照不可用。当前仍为本地动作合同,未检查线上兼容性;可从可信来源构建或重新安装以取得相关指南。\n",
|
|
174
|
+
"help.diagnose": "用法:diagnose login --space-id ID --app-id ID --environment sandbox|production [--limit 1..50] [--cursor CURSOR]\n\n只读应用登录配置诊断。使用精确配置的issuer与API Origin,先比较公开动作合同,再通过正式委托API读取应用和一页客户端。不登录、更新、开页、写入或自动翻页。\n支持 --format json|table|csv|ndjson。ok:true仅表示生成了报告,请核对status和coverage;配置观察不证明真实登录成功。\n",
|
|
175
|
+
"diagnosis.usage": "使用 diagnose login --space-id ID --app-id ID --environment sandbox|production [--limit 1..50] [--cursor CURSOR]。",
|
|
176
|
+
"diagnosis.contractUnavailable": "本地合同缺少共享只读登录诊断动作。",
|
|
177
|
+
"diagnosis.contractsObserved": "公开诊断动作合同相同,原领域仍按当前权限检查每个来源。",
|
|
178
|
+
"diagnosis.contractsBlocked": "未能匹配当前动作合同,未请求业务资料。",
|
|
179
|
+
"diagnosis.invalidResponse": "来源未返回符合合同及本次范围的响应。",
|
|
180
|
+
"diagnosis.sourceUnavailable": "来源不可用({{code}}),其结果仍未检查。",
|
|
181
|
+
"diagnosis.context": "空间:{{space_id}} 应用:{{application_id}} 环境:{{environment}}",
|
|
182
|
+
"diagnosis.conclusion": "报告:{{status}} 观察时间:{{observed}}",
|
|
183
|
+
"diagnosis.observationOnly": "仅为只读配置观察,各来源不共享快照,真实登录未验证。",
|
|
184
|
+
"diagnosis.nextCursor": "下一页游标(须以相同范围显式继续):",
|
|
185
|
+
"columns.check": "检查项",
|
|
186
|
+
"columns.status": "状态",
|
|
187
|
+
"columns.message": "说明",
|
|
188
|
+
"diagnosis.targets": "身份服务:{{issuer}} API:{{api_origin}}",
|
|
189
|
+
"help.task": "用法:limecloud-cli task group prepare --space-id ID --data @change.json\n limecloud-cli task group get|receipt|resolve --space-id ID --preparation-id ID\n limecloud-cli task group execute --space-id ID --preparation-id ID --confirm DIGEST\n先核对完整原准备再确认。发送前在本地记录一次执行尝试;按原 ID 恢复,不重复执行。\n",
|
|
190
|
+
"groupTask.usage": "使用 task group prepare --space-id ID --data JSON,或 get|receipt|resolve --space-id ID --preparation-id ID;execute 还须传入 --confirm 和精确确认摘要。不接受替换命令、--yes、字段裁剪或隐式确认。",
|
|
191
|
+
"groupTask.contractUnavailable": "随包成员组任务合同暂不可用。",
|
|
192
|
+
"groupTask.contextRequired": "执行任务前须明确配置 API 来源和受控认证 Client ID。",
|
|
193
|
+
"groupTask.invalidResponse": "成员组响应不符合原任务合同或调用绑定。",
|
|
194
|
+
"groupTask.referenceConflict": "本地恢复引用与本次调用或原准备不符。请保留引用并核对原 Issuer、API、客户端、操作者及企业。",
|
|
195
|
+
"groupTask.referenceRequired": "确认执行前,请先用 task group get 读取原准备并保存恢复引用。",
|
|
196
|
+
"groupTask.alreadyAttempted": "原键已有一次执行尝试记录。请读取原回执或显式终结原操作;此任务不会重复执行。",
|
|
197
|
+
"groupTask.confirmationChanged": "确认摘要或执行合同与原准备引用不符。请先核对或终结原操作,再准备新变更。",
|
|
198
|
+
"groupTask.reviewHint": "请核对操作者、企业、调用范围、原值、冻结命令、完整影响及期限。明确核对后,再将原准备 ID 和 confirmation_digest 传给 task group execute --confirm。摘要不证明人的同意,也不授予权限。",
|
|
199
|
+
"groupTask.recoveryHint": "使用原 ID 读取准备或回执,或显式终结原操作。没有回执不能证明未提交。不得换键或自动重复执行、登录、更新及开页。",
|
|
200
|
+
"groupTask.resultHint": "结果来自原领域 Owner。后续动作仍须核对当前权限;本地引用不是授权或业务状态缓存。",
|
|
201
|
+
"groupTask.tableHeading": "字段 完整任务值(可换行,不截断)",
|
|
202
|
+
"help.skills": "用法:limecloud-cli skills list | inspect ID [--online --issuer ISSUER --api-origin API_ORIGIN] | export ID --host codex|skill --output-dir NEW_DIRECTORY --digest EXACT_DIGEST\n默认离线检查;--online 不带凭据,逐项比较所需动作与所选部署。授权、来源证明、撤回和宿主加载分别判断。默认包装为 codex。导出不安装、启用、更新或加载宿主插件。\n",
|
|
203
|
+
"skills.loginTitle": "应用登录配置诊断",
|
|
204
|
+
"skills.loginDescription": "在现有 Agent 宿主内组织一个 Lime 应用的只读登录配置诊断;真实登录及修复属于独立任务。",
|
|
205
|
+
"skills.groupTitle": "单个企业成员组变更",
|
|
206
|
+
"skills.groupDescription": "组织一个组的创建、改名或状态变更,完整核对准备、明确确认并恢复原结果;成员增删、角色和批量变更属于独立任务。",
|
|
207
|
+
"skills.usage": "使用 skills list、skills inspect ID [--online --issuer ISSUER --api-origin API_ORIGIN],或 skills export ID --output-dir NEW_DIRECTORY --digest EXACT_DIGEST;可用 --host codex|skill 选择包装,不自动安装或修改宿主。",
|
|
208
|
+
"skills.invalidArtifact": "随包公开 Skill 制品不完整或摘要不一致。请从可信来源取得 CLI;本次未导出制品。",
|
|
209
|
+
"skills.candidateNotice": "仅为随包公开候选;完整性与当前已安装 CLI 一致,线上兼容、来源证明、撤回及宿主加载均未验证。先检查全部文件,再独立选择原生安装或导入。",
|
|
210
|
+
"skills.onlineNotice": "仅在记录时间逐项比较候选所需动作与所选部署的合同。请核对全部动作及期限;匹配不代表权限、来源证明、撤回状态,也不证明方法已安装或当前会话已加载。没有安装、更新或执行业务动作。",
|
|
211
|
+
"skills.exportedNotice": "完整候选已导出;本次未安装、启用、更新或加载方法,未修改宿主配置或执行制品脚本。原生插件安装/更新由所选宿主管理器负责,当前会话加载状态未知。",
|
|
212
|
+
"skills.notInstallation": "请导出到 Agent 发现/配置目录以外的新普通目录;明确选择安装或更新时使用实际宿主管理器。",
|
|
213
|
+
"skills.unsafeDirectory": "导出父路径必须是已存在的真实目录,祖先路径不得包含符号链接。",
|
|
214
|
+
"skills.confirmationChanged": "所选制品摘要不一致,请先检查该精确包装和候选再导出。",
|
|
215
|
+
"skills.destinationExists": "导出目的目录已存在,请选择新目录;不覆盖已有文件或本地修改。",
|
|
216
|
+
"skills.notFound": "当前 CLI 未包含该公开方法或包装,本次未尝试下载、安装或更新。"
|
|
153
217
|
}
|
package/src/management.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { resolveSchema } from "./contract.mjs";
|
|
4
4
|
import { jsonInput, readSource } from "./input.mjs";
|
|
5
5
|
import { CLIError } from "./protocol.mjs";
|
|
6
|
+
import { actionContract } from "./action-contracts.mjs";
|
|
7
|
+
|
|
8
|
+
export const actionContractHeader = "Lime-Action-Contract";
|
|
6
9
|
|
|
7
10
|
export const flagName = (field) => field.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replaceAll("_", "-").toLowerCase();
|
|
8
11
|
const fail = (message) => { throw new CLIError("invalid_arguments", message); };
|
|
@@ -108,6 +111,7 @@ export async function prepareManagement(contract, operation, parsed, env, readIn
|
|
|
108
111
|
function apply(name, raw, privateInput) {
|
|
109
112
|
if (!privateInput && sensitive.has(name)) fail(message("arguments.privateFieldRequired"));
|
|
110
113
|
const parameter = declaration(name);
|
|
114
|
+
if (name.toLowerCase() === actionContractHeader.toLowerCase()) fail(message("arguments.managedHeader"));
|
|
111
115
|
const property = inputSchema?.properties?.[name];
|
|
112
116
|
if (!parameter && !property) fail(message("arguments.unknownParameter", { name }));
|
|
113
117
|
if (parameter) values[parameter.name] = cast(raw, parameter.schema);
|
|
@@ -131,8 +135,10 @@ export async function prepareManagement(contract, operation, parsed, env, readIn
|
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
for (const key of Object.keys(values)) if (!declaration(key)) fail(message("arguments.unknownRequestParameter", { name: key }));
|
|
138
|
+
if (Object.keys(values).some(name => name.toLowerCase() === actionContractHeader.toLowerCase())) fail(message("arguments.managedHeader"));
|
|
134
139
|
const query = new URLSearchParams(), headers = {}, bindings = { ...rawBindings };
|
|
135
140
|
for (const parameter of declarations) {
|
|
141
|
+
if (parameter.in === "header" && parameter.name.toLowerCase() === actionContractHeader.toLowerCase()) continue;
|
|
136
142
|
let value = values[parameter.name];
|
|
137
143
|
if (parameter.in === "path" && rawBindings[parameter.name] !== undefined) {
|
|
138
144
|
if (value !== undefined && String(value) !== rawBindings[parameter.name]) fail(message("arguments.pathConflict", { name: parameter.name }));
|
|
@@ -155,6 +161,7 @@ export async function prepareManagement(contract, operation, parsed, env, readIn
|
|
|
155
161
|
});
|
|
156
162
|
if (operation.requestBody?.required && !bodyProvided) fail(binaryType ? message("arguments.fileRequired") : message("arguments.dataRequired"));
|
|
157
163
|
if (bodyProvided && !binaryBody) validateJSON(contract, inputSchema, body);
|
|
164
|
+
if (operation.method !== "GET" && operation.method !== "HEAD") headers[actionContractHeader] = actionContract(contract, operation).contract_revision;
|
|
158
165
|
return { path, query, extraHeaders: headers, ...(binaryBody ? { binaryBody, binaryType,
|
|
159
166
|
binarySummary: { media_type: binaryType, bytes: binaryBody.length, width: 256, height: 256, sha256: createHash("sha256").update(binaryBody).digest("hex") } } :
|
|
160
167
|
bodyProvided ? { jsonBody: body } : {}) };
|
package/src/output.mjs
CHANGED
|
@@ -5,6 +5,7 @@ export const formats = ["json", "ndjson", "table", "csv"];
|
|
|
5
5
|
const columnLabels = {
|
|
6
6
|
command: "columns.command", operationId: "columns.operationId", method: "columns.method",
|
|
7
7
|
path: "columns.path", summary: "columns.summary", description: "columns.description", value: "columns.value",
|
|
8
|
+
check: "columns.check", status: "columns.status", message: "columns.message",
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
export function outputOptions(parsed) {
|
|
@@ -25,7 +26,7 @@ function select(value, fields) {
|
|
|
25
26
|
current && typeof current === "object" && Object.hasOwn(current, key) ? current[key] : null, value)]));
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function
|
|
29
|
+
export function displayText(value) {
|
|
29
30
|
const result = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
30
31
|
return result.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
31
32
|
}
|
|
@@ -44,9 +45,9 @@ export function success(io, value, { format = "json", fields, identity = "public
|
|
|
44
45
|
io.stdout.write(`${[keys, ...rows.map((row) => keys.map((key) => typeof row[key] === "object" && row[key] !== null ? JSON.stringify(row[key]) : row[key]))].map((row) => row.map(quote).join(",")).join("\n")}\n`);
|
|
45
46
|
} else {
|
|
46
47
|
const headings = keys.map(key => Object.hasOwn(columnLabels, key) ? currentLocalizer().t(columnLabels[key]) : key);
|
|
47
|
-
const cells = [headings, ...rows.map((row) => keys.map((key) =>
|
|
48
|
+
const cells = [headings, ...rows.map((row) => keys.map((key) => displayText(row[key])))];
|
|
48
49
|
const widths = keys.map((_key, index) => Math.min(80, Math.max(...cells.map((row) => row[index].length))));
|
|
49
|
-
io.stdout.write(`${cells.map((row) => row.map((cell, index) =>
|
|
50
|
+
io.stdout.write(`${cells.map((row) => row.map((cell, index) => displayText(cell).slice(0, widths[index]).padEnd(widths[index])).join(" ").trimEnd()).join("\n")}\n`);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -54,8 +55,10 @@ export function success(io, value, { format = "json", fields, identity = "public
|
|
|
54
55
|
export function errorEnvelope(error, cancelled, localizer = currentLocalizer()) {
|
|
55
56
|
const known = error instanceof CLIError;
|
|
56
57
|
const code = cancelled ? "cancelled" : known ? error.code : "cli_error";
|
|
57
|
-
const type = error?.status ? "api" : ["login_required", "token_expired", "missing_scopes"].includes(code) ? "auth" :
|
|
58
|
+
const type = error?.status ? "api" : known && error.compatibility ? "compatibility" : ["login_required", "token_expired", "missing_scopes"].includes(code) ? "auth" :
|
|
58
59
|
code === "request_failed" ? "transport" : code === "invalid_arguments" || code === "invalid_issuer" ? "validation" : "cli";
|
|
59
60
|
return { ok: false, error: { type, ...(type === "validation" ? { subtype: "invalid_argument" } : {}), code, message: cancelled ? localizer.t("errors.cancelled") : known ? error.message : localizer.t("errors.failed"),
|
|
60
|
-
...(known && error.status ? { status: error.status } : {}), ...(known && error.
|
|
61
|
+
...(known && error.status ? { status: error.status } : {}), ...(known && error.compatibility ? { compatibility: error.compatibility } : {}),
|
|
62
|
+
...(error?.recovery ? { recovery: error.recovery } : {}),
|
|
63
|
+
...(known && error.retryAfter ? { retry_after: error.retryAfter } : {}) } };
|
|
61
64
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { currentLocalizer, defaultLocale, localeRegistry, message, translate } from "./i18n.mjs";
|
|
6
|
+
import { CLIError } from "./protocol.mjs";
|
|
7
|
+
import { actionContract } from "./action-contracts.mjs";
|
|
8
|
+
import { commandOperations, inspectSchema } from "./commands.mjs";
|
|
9
|
+
import { attachContractCompatibility } from "./compatibility.mjs";
|
|
10
|
+
|
|
11
|
+
export const skillWrappers = ["codex", "skill"];
|
|
12
|
+
const httpMethods = new Set(["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]);
|
|
13
|
+
// 仅概括正式HTTP动作,供安装前审阅;不表示当前权或已获得人的确认。
|
|
14
|
+
export const skillEffect = actions => actions.every(action => ["GET", "HEAD", "OPTIONS"].includes(action.method)) ? "read" : "write";
|
|
15
|
+
const sha = value => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
16
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === "object"
|
|
17
|
+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
18
|
+
const safePath = value => typeof value === "string" && value.length <= 240 && !isAbsolute(value) &&
|
|
19
|
+
value.split("/").every(part => /^[A-Za-z0-9_.-]+$/.test(part) && ![".", ".."].includes(part)) && !value.includes("\\");
|
|
20
|
+
export const skillArtifactDigest = artifact => sha(JSON.stringify(canonical({ ...artifact, digest: undefined,
|
|
21
|
+
files: artifact.files.map(({ content, ...file }) => file) })));
|
|
22
|
+
const invalid = () => { throw new CLIError("skill_artifact_invalid", message("skills.invalidArtifact")); };
|
|
23
|
+
|
|
24
|
+
// 只验证随已安装CLI提供的公开候选。摘要检查完整性,不构造独立发布证明或线上撤回结论。
|
|
25
|
+
export function validateSkillBundle(bundle, { registry = localeRegistry, version, contract } = {}) {
|
|
26
|
+
if (bundle?.schema_version !== 1 || bundle.package !== "@limecloud/cli" || !/^\d+\.\d+\.\d+$/.test(bundle.version) ||
|
|
27
|
+
version && bundle.version !== version || !Array.isArray(bundle.artifacts) || bundle.artifacts.length > 32) invalid();
|
|
28
|
+
const identities = new Set();
|
|
29
|
+
const actions = contract && new Map(commandOperations(contract).map(operation => [operation.operationId, actionContract(contract, operation)]));
|
|
30
|
+
for (const artifact of bundle.artifacts) {
|
|
31
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) invalid();
|
|
32
|
+
const identity = `${artifact.id}:${artifact.wrapper}`;
|
|
33
|
+
if (!/^limecloud-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(artifact.id) || !skillWrappers.includes(artifact.wrapper) || identities.has(identity) ||
|
|
34
|
+
artifact.version !== bundle.version || artifact.publisher !== "limecloud" || artifact.source !== "@limecloud/cli" || artifact.visibility !== "public" ||
|
|
35
|
+
artifact.update_owner !== (artifact.wrapper === "codex" ? "native-host-manager" : "explicit-import-owner") ||
|
|
36
|
+
!Array.isArray(artifact.operations) || !artifact.operations.length || !artifact.operations.every(item => typeof item?.operation_id === "string" && httpMethods.has(item.method) && /^sha256:[a-f0-9]{64}$/.test(item.contract_revision)) ||
|
|
37
|
+
artifact.effect !== skillEffect(artifact.operations) ||
|
|
38
|
+
!Array.isArray(artifact.files) || artifact.files.length < 1 || artifact.files.length > 20) invalid();
|
|
39
|
+
identities.add(identity);
|
|
40
|
+
// 同一CLI内混入另一份动作资料时先拒绝,不能拿当前Schema替候选悄悄解释旧步骤。
|
|
41
|
+
if (new Set(artifact.operations.map(action => action.operation_id)).size !== artifact.operations.length || actions && artifact.operations.some(action =>
|
|
42
|
+
!actions.has(action.operation_id) || JSON.stringify(canonical(action)) !== JSON.stringify(canonical(actions.get(action.operation_id))))) invalid();
|
|
43
|
+
for (const { code } of registry.locales) {
|
|
44
|
+
if (!["title", "description"].every(key => typeof artifact.localizations?.[code]?.[key] === "string" && artifact.localizations[code][key].trim())) invalid();
|
|
45
|
+
}
|
|
46
|
+
const prefix = artifact.wrapper === "codex" ? `skills/${artifact.id}/` : "";
|
|
47
|
+
const paths = new Set();
|
|
48
|
+
let bytes = 0;
|
|
49
|
+
for (const file of artifact.files) {
|
|
50
|
+
if (!file || !safePath(file.path) || paths.has(file.path) || typeof file.content !== "string" || !/\.(?:md|json)$/.test(file.path) ||
|
|
51
|
+
!(file.path === "plugin.json" && artifact.wrapper === "codex" || file.path === `${prefix}SKILL.md` || file.path.startsWith(`${prefix}references/`) && file.path.endsWith(".md")) ||
|
|
52
|
+
Buffer.byteLength(file.content) !== file.bytes || file.sha256 !== sha(file.content) || file.bytes > 131072) invalid();
|
|
53
|
+
paths.add(file.path); bytes += file.bytes;
|
|
54
|
+
}
|
|
55
|
+
if (bytes > 524288 || !paths.has(`${prefix}SKILL.md`) || artifact.wrapper === "codex" && !paths.has("plugin.json") || artifact.digest !== skillArtifactDigest(artifact)) invalid();
|
|
56
|
+
const entry = artifact.files.find(file => file.path === `${prefix}SKILL.md`).content;
|
|
57
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(entry)?.[1];
|
|
58
|
+
if (!frontmatter || /^name:\s*([a-z0-9-]+)\s*$/m.exec(frontmatter)?.[1] !== artifact.id || !/^description:\s*\S.+$/m.test(frontmatter)) invalid();
|
|
59
|
+
for (const { code } of registry.locales) {
|
|
60
|
+
const instruction = code === registry.defaultLocale ? artifact.instructions?.source : artifact.instructions?.translations?.[code];
|
|
61
|
+
if (!paths.has(instruction) || !instruction.startsWith(`${prefix}references/`)) invalid();
|
|
62
|
+
}
|
|
63
|
+
if (artifact.wrapper === "codex") {
|
|
64
|
+
let manifest;
|
|
65
|
+
try { manifest = JSON.parse(artifact.files.find(file => file.path === "plugin.json").content); } catch { invalid(); }
|
|
66
|
+
if (manifest.$schema !== "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" || manifest.name !== artifact.id || manifest.version !== bundle.version ||
|
|
67
|
+
Object.keys(manifest).some(key => !["$schema", "name", "version", "description"].includes(key))) invalid();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return bundle;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function loadSkillBundle(version) {
|
|
74
|
+
try { return validateSkillBundle(JSON.parse(await readFile(new URL("../dist/skills.json", import.meta.url), "utf8")), { version }); }
|
|
75
|
+
catch (error) { if (error instanceof CLIError) throw error; invalid(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function describeSkill(artifact, { includeFiles = false } = {}) {
|
|
79
|
+
const locale = currentLocalizer().locale;
|
|
80
|
+
const { files, localizations, ...metadata } = artifact;
|
|
81
|
+
return { ...metadata, ...(localizations[locale] ?? localizations[defaultLocale]),
|
|
82
|
+
instruction_file: locale === defaultLocale ? artifact.instructions.source : artifact.instructions.translations[locale] ?? artifact.instructions.source,
|
|
83
|
+
requested_locale: locale, files: files.map(({ content, ...file }) => includeFiles ? { ...file, content } : file),
|
|
84
|
+
compatibility: { source: "bundled-action-contracts", remote: "not-evaluated", authorization: "not-evaluated" },
|
|
85
|
+
trust: { source: "inherited-from-installed-cli", integrity: "matched", provenance: "not-evaluated", online_withdrawal: "not-evaluated" },
|
|
86
|
+
installation: { performed_by_this_command: false, on_disk: "not-evaluated", current_session_loaded: "unknown" }, message: translate("skills.candidateNotice") };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function inspectSkill(artifact, contract, options = {}) {
|
|
90
|
+
const description = describeSkill(artifact, { includeFiles: true });
|
|
91
|
+
if (!options.online) return description;
|
|
92
|
+
const methods = artifact.operations.map(action => inspectSchema(contract, [action.operation_id]));
|
|
93
|
+
const checked = await attachContractCompatibility(methods, contract, options);
|
|
94
|
+
const actions = checked.map(method => ({ operation_id: method._meta.operationId, contract_revision: method._meta.contract_revision,
|
|
95
|
+
...method._meta.compatibility }));
|
|
96
|
+
const statuses = new Set(actions.map(action => action.status));
|
|
97
|
+
return { ...description, compatibility: { ...description.compatibility,
|
|
98
|
+
remote: statuses.has("different") ? "different" : statuses.has("absent") ? "absent" : statuses.has("unknown") ? "unknown" : "matched",
|
|
99
|
+
complete: !statuses.has("unknown"), checked_at: new Date((options.now ?? Date.now)()).toISOString(), actions },
|
|
100
|
+
message: translate("skills.onlineNotice") };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function physicalDirectory(path) {
|
|
104
|
+
const absolute = resolve(path);
|
|
105
|
+
if (absolute.split(sep).some(part => [".agents", ".codex", ".claude", ".workbuddy"].includes(part))) {
|
|
106
|
+
throw new CLIError("skill_export_unsafe", message("skills.notInstallation"));
|
|
107
|
+
}
|
|
108
|
+
let cursor = absolute;
|
|
109
|
+
for (;;) {
|
|
110
|
+
const stat = await lstat(cursor);
|
|
111
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CLIError("skill_export_unsafe", message("skills.unsafeDirectory"));
|
|
112
|
+
if (cursor === dirname(cursor)) break;
|
|
113
|
+
cursor = dirname(cursor);
|
|
114
|
+
}
|
|
115
|
+
return realpath(absolute);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function exportSkill(artifact, destination, confirmation) {
|
|
119
|
+
if (confirmation !== artifact.digest) throw new CLIError("skill_confirmation_changed", message("skills.confirmationChanged"));
|
|
120
|
+
if (typeof destination !== "string" || !destination || destination.length > 2048 || /[\x00-\x1f\x7f]/.test(destination) ||
|
|
121
|
+
resolve(destination).split(sep).some(part => [".agents", ".codex", ".claude", ".workbuddy"].includes(part))) {
|
|
122
|
+
throw new CLIError("skill_export_unsafe", message("skills.notInstallation"));
|
|
123
|
+
}
|
|
124
|
+
const target = resolve(destination), parent = await physicalDirectory(dirname(target));
|
|
125
|
+
if (target !== join(parent, basename(target))) throw new CLIError("skill_export_unsafe", message("skills.unsafeDirectory"));
|
|
126
|
+
try { await lstat(target); throw new CLIError("skill_export_exists", message("skills.destinationExists")); }
|
|
127
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
128
|
+
const stage = await mkdtemp(join(parent, ".limecloud-export-"));
|
|
129
|
+
let reserved = false;
|
|
130
|
+
try {
|
|
131
|
+
for (const file of artifact.files) {
|
|
132
|
+
const path = join(stage, file.path);
|
|
133
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
134
|
+
const handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
135
|
+
try { await handle.writeFile(file.content); await handle.sync(); } finally { await handle.close(); }
|
|
136
|
+
}
|
|
137
|
+
// 不覆盖任何既有目的目录;占位目录在全部文件就绪后才创建,最终rename只替换空占位。
|
|
138
|
+
try { await mkdir(target, { mode: 0o700 }); reserved = true; }
|
|
139
|
+
catch (error) { if (error.code === "EEXIST") throw new CLIError("skill_export_exists", message("skills.destinationExists")); throw error; }
|
|
140
|
+
await rename(stage, target);
|
|
141
|
+
return { ...describeSkill(artifact), export: { path: target, completed: true }, message: translate("skills.exportedNotice") };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (reserved) await rmdir(target).catch(() => {});
|
|
144
|
+
throw error;
|
|
145
|
+
} finally { await rm(stage, { recursive: true, force: true }); }
|
|
146
|
+
}
|
package/src/protocol.mjs
CHANGED
|
@@ -82,7 +82,8 @@ export async function protocolRequest({ issuer, origin = issuer, path, method =
|
|
|
82
82
|
const code = typeof errorCode === "string" && /^[A-Za-z0-9_.:-]{1,80}$/.test(errorCode) ? errorCode : "http_error";
|
|
83
83
|
// Do not echo error descriptions: a server may include submitted secrets in them.
|
|
84
84
|
const retry = Number(response.headers.get("Retry-After"));
|
|
85
|
-
|
|
85
|
+
const contractErrors = { ACTION_CONTRACT_REQUIRED: "protocol.contractRequired", ACTION_CONTRACT_CHANGED: "protocol.contractChanged", ACTION_CONTRACT_UNAVAILABLE: "protocol.contractUnavailable" };
|
|
86
|
+
throw new CLIError(code, message(Object.hasOwn(contractErrors, code) ? contractErrors[code] : "protocol.apiFailed", { code, status: response.status }), response.status,
|
|
86
87
|
Number.isFinite(retry) && retry > 0 ? retry : undefined);
|
|
87
88
|
}
|
|
88
89
|
return data;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { currentLocalizer } from "./i18n.mjs";
|
|
2
|
+
|
|
3
|
+
// 公开元数据传输不接受任何凭据,不跟随重定向;调用方负责目标和业务格式校验。
|
|
4
|
+
export async function fetchPublicJSON(url, { signal, maxBytes = 8 * 1024 * 1024 } = {}) {
|
|
5
|
+
const response = await fetch(url, { headers: { Accept: "application/json", "Accept-Language": currentLocalizer().locale },
|
|
6
|
+
redirect: "error", credentials: "omit", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000) });
|
|
7
|
+
if (!response.ok || !response.headers.get("content-type")?.includes("application/json") || Number(response.headers.get("content-length")) > maxBytes) {
|
|
8
|
+
await response.body?.cancel(); throw new Error("Public JSON unavailable");
|
|
9
|
+
}
|
|
10
|
+
const chunks = []; let size = 0;
|
|
11
|
+
for await (const chunk of response.body) {
|
|
12
|
+
size += chunk.length;
|
|
13
|
+
if (size > maxBytes) throw new Error("Public JSON exceeds limit");
|
|
14
|
+
chunks.push(chunk);
|
|
15
|
+
}
|
|
16
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
17
|
+
}
|
package/src/storage.mjs
CHANGED
|
@@ -20,8 +20,13 @@ function checkPrivate(stat) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
async function syncDirectory(path) {
|
|
24
|
+
const directory = await open(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
25
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
export class PrivateJSONStore {
|
|
24
|
-
constructor(path) { this.path = path; }
|
|
29
|
+
constructor(path, { durable = false } = {}) { this.path = path; this.durable = durable; }
|
|
25
30
|
async read() {
|
|
26
31
|
let file;
|
|
27
32
|
try {
|
|
@@ -43,9 +48,21 @@ export class PrivateJSONStore {
|
|
|
43
48
|
try { await file.writeFile(`${JSON.stringify(record)}\n`); await file.sync(); }
|
|
44
49
|
finally { await file.close(); }
|
|
45
50
|
await rename(temp, this.path);
|
|
51
|
+
if (this.durable) {
|
|
52
|
+
// 发送标记须在请求前落盘:文件fsync之外,还要同步rename所在目录。
|
|
53
|
+
// 不支持目录同步的文件系统失败关闭,调用方不得继续执行网络写入。
|
|
54
|
+
await syncDirectory(dirname(this.path));
|
|
55
|
+
}
|
|
46
56
|
}
|
|
47
57
|
async withLock(callback) {
|
|
48
|
-
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
58
|
+
const created = await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
59
|
+
if (this.durable && created) {
|
|
60
|
+
const parent = dirname(created);
|
|
61
|
+
for (let path = dirname(this.path);; path = dirname(path)) {
|
|
62
|
+
await syncDirectory(path);
|
|
63
|
+
if (path === parent) break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
49
66
|
const lockPath = `${this.path}.lock`;
|
|
50
67
|
let lock;
|
|
51
68
|
try { lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); }
|