Explorar el Código

skill-storage v0.1.0: 对象存储上传/公开链接/ACL, 4级凭据解析, 多工具指南

liuyuyang hace 1 semana
commit
810c5804e0
Se han modificado 6 ficheros con 222 adiciones y 0 borrados
  1. 4 0
      .gitignore
  2. 21 0
      LICENSE
  3. 57 0
      README.md
  4. 10 0
      package.json
  5. 40 0
      skills/fmode-storage/SKILL.md
  6. 90 0
      skills/fmode-storage/scripts/uploader.mjs

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+*.tgz
+.env
+output/

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 fmodecn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 57 - 0
README.md

@@ -0,0 +1,57 @@
+# skill-storage · 对象存储与公开分享技能
+
+> AI Agent 的"仓库管理员"——二进制大文件(图片/音频/视频/报告 HTML)上云,本地零占用,一键生成公开分享链接。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+
+## 能力
+
+- 📤 大文件上传对象存储(华为云 OBS / S3 协议),本地磁盘零长期占用
+- 🔗 生成公开分享链接(`https://<域名>/<key>` 形态,可配 CDN)
+- 📄 报告/课件 HTML 发布即分享(传完即得可转发 URL)
+- 🧹 生命周期管理(前缀用量统计/批量清理/ACL 设置)
+
+## 各工具安装
+
+### Claude Code
+```bash
+git clone https://git.fmode.cn/fmode/skill-storage.git
+cp -r skill-storage/skills/fmode-storage ~/.claude/skills/fmode-storage
+```
+之后在会话里直接说:「把这个报告上传并给我公开链接」。
+
+### Codex / Gemini CLI
+把 `skills/fmode-storage/SKILL.md` 的内容并入 `AGENTS.md`(Codex)或 `~/.gemini/commands/storage.toml`(Gemini CLI,prompt 段引用 scripts/uploader.mjs)。
+
+### WorkBuddy / Hermes
+```bash
+git clone https://git.fmode.cn/fmode/skill-storage.git
+cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storage
+```
+
+## 凭据(零密钥入库)
+
+运行器按优先级自动解析(检测不到就换下一级,绝不空转):
+
+1. 环境变量 `FMODE_STORAGE_TOKEN` / `FMODE_OBS_CONF`(OBS util 配置文件路径)
+2. `~/.fmode/config.json` → `obsUtilPath` / `obsBucket` / `obsEndpoint` / `cdnDomain`
+3. 项目 `./.fmode/config.json` → 同上
+4. 华为云 SDK 默认链(若装了 obsutil/huaweicloud-sdk 且已 `obsutil config`)
+
+> obsutil 配置方法(一次性):`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
+> **不要把 AK/SK 写进本仓库或任何对话。**
+
+## 用法示例
+
+```bash
+# 上传并输出公开链接
+node skills/fmode-storage/scripts/uploader.mjs put ./report.html --key reports/20260911/report.html
+# → https://fmode.cn/reports/20260911/report.html
+
+# 设置公开读
+node skills/fmode-storage/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
+```
+
+## License
+
+MIT

+ 10 - 0
package.json

@@ -0,0 +1,10 @@
+{
+  "name": "fmode-storage",
+  "version": "0.1.0",
+  "description": "Claude Code skill: 录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频时长计费。提供 `npx fmode-listen transcribe` 直连命令 + Claude Code 技能安装器。",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+ssh://git@github.com/fmodecn/skill-storage.git"
+  }
+}

+ 40 - 0
skills/fmode-storage/SKILL.md

@@ -0,0 +1,40 @@
+---
+name: fmode-storage
+description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储(OBS/S3),本地零长期占用,生成公开分享链接。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+ACL 设置 (4) 需要公开 URL 供转发或嵌入。AK/SK 用 obsutil config 配置,凭据不入库。"
+description_en: "Upload binary files (images/audio/video/HTML reports) to object storage (OBS/S3), keep local disk clean, and get public share URLs instantly. Use for publishing reports/courseware, hosting media assets, batch upload with ACL, or any scenario needing public URLs."
+---
+
+# Fmode Storage — 对象存储与公开分享技能
+
+## Overview
+
+本技能把本地二进制文件交给对象存储(默认华为云 OBS,S3 协议兼容),生成公开 URL 供分享、转发或嵌入。设计原则:**本地零长期占用**——大文件直接上云;**公开即所得**——上传后立即可访问的 URL;**凭据零入库**——AK/SK 只在 obsutil/环境配置里。
+
+## 快速用
+
+```bash
+node <skill_dir>/scripts/uploader.mjs put ./report.html --key reports/20260911/report.html
+# → { "url": "https://fmode.cn/reports/20260911/report.html", ... }
+
+node <skill_dir>/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
+```
+
+## 凭据(4 级自动解析)
+
+1. 环境变量 `FMODE_OBS_CONF`(JSON 文件路径)
+2. `~/.fmode/config.json` → `obsBucket` / `obsEndpoint` / `cdnDomain` / `obsUtilPath`
+3. 项目 `./.fmode/config.json`
+4. obsutil 默认链(已 `obsutil config` 则自动探测 bucket)
+
+> AK/SK 配置一次:`obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com`
+> **禁止把 AK/SK 或任何密钥写进仓库、日志或对话。**
+
+## 何时用
+
+- 生成了 HTML 报告/课件要"发给别人看"→ put 后把 URL 发出
+- 图片/音频/视频素材需要稳定外链 → put + setacl public-read
+- 本地磁盘被大文件占满 → put 完成后可删本地原件
+
+## 详细文档
+
+见仓库根 `README.md`(多工具安装:Claude Code / Codex / Gemini CLI / WorkBuddy / Hermes)。

+ 90 - 0
skills/fmode-storage/scripts/uploader.mjs

@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+#!/usr/bin/env node
+/**
+ * fmode-storage uploader — 对象存储上传/公开链接/ACL
+ * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
+ *
+ * 用法:
+ *   node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
+ *   node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
+ */
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { execSync, spawnSync } from 'child_process';
+
+const HOME = os.homedir();
+
+/** 4 级凭据/配置解析: env → ~/.fmode/config.json → ./.fmode/config.json → obsutil 默认链 */
+export function resolveStorageConfig() {
+  const cfg = { obsUtilPath: 'obsutil', endpoint: 'obs.cn-south-1.myhuaweicloud.com', bucket: null, cdnDomain: null };
+  // 1) 环境变量
+  if (process.env.FMODE_OBS_CONF) {
+    try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); } catch {}
+  }
+  // 2) ~/.fmode/config.json
+  for (const p of [path.join(HOME, '.fmode', 'config.json'), path.join(process.cwd(), '.fmode', 'config.json')]) {
+    try {
+      const j = JSON.parse(fs.readFileSync(p, 'utf8'));
+      if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
+      if (j.obsBucket) cfg.bucket = j.obsBucket;
+      if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
+      if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
+    } catch {}
+  }
+  if (!cfg.bucket) {
+    // 3) obsutil 默认链: 若 obsutil 已配置, 从其 log/配置探测 bucket
+    try {
+      const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
+      const m = out.match(/obs:\/\/([^/]+)/);
+      if (m) cfg.bucket = m[1];
+    } catch {}
+  }
+  return cfg;
+}
+
+/** 公开 URL: cdnDomain 优先, 否则桶域名 */
+export function publicUrl(cfg, key) {
+  if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
+  return `https://${cfg.bucket}.${cfg.endpoint}/${key}`.replace('.myhuaweicloud.com', '.myhuaweicloud.com');
+}
+
+function obs(args, cfg, timeout = 120000) {
+  const r = spawnSync(cfg.obsUtilPath, [...args, '-e', cfg.endpoint], { encoding: 'utf8', timeout });
+  const out = (r.stdout || '') + (r.stderr || '');
+  return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
+}
+
+function main() {
+  const [cmd, ...rest] = process.argv.slice(2);
+  const cfg = resolveStorageConfig();
+  if (!cfg.bucket && cmd !== 'help') {
+    console.error('未解析到 bucket:请配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
+    console.error('(技能绝不内置密钥;AK/SK 用 obsutil config 自行配置)');
+    process.exit(2);
+  }
+  const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
+
+  if (cmd === 'put') {
+    const file = rest[0];
+    let key = arg('--key');
+    if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
+    const acl = arg('--acl') || 'public-read';
+    const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
+    const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`], cfg);
+    if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
+    console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket }, null, 2));
+    return;
+  }
+  if (cmd === 'setacl') {
+    const key = arg('--key'); const acl = arg('--acl') || 'public-read';
+    const recursive = rest.includes('-r');
+    const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])], cfg);
+    console.log(JSON.stringify({ ok: r.ok }, null, 2));
+    return;
+  }
+  if (cmd === 'config') { console.log(JSON.stringify({ ...cfg }, null, 2)); return; }
+  console.log('用法: put | setacl | config');
+}
+
+main();