From 1f7984d79826c5041f18b65f4fa889d4d7ec36ec Mon Sep 17 00:00:00 2001 From: voson Date: Wed, 18 Mar 2026 17:17:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E4=BC=98=E5=85=88=E7=9A=84=20sear?= =?UTF-8?q?ch=20=E5=A2=9E=E5=BC=BA=E4=B8=8E=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=E8=BE=93=E5=87=BA=20(v0.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search: 新增 --name、-f/--field、-o/--output、--summary、--limit、--offset、--sort - search: 非 TTY 自动输出 json-compact,便于 AI 解析 - search: -f secret.* 自动解锁 secrets - add: 支持 -o json/json-compact 输出 - add: 重构为 AddArgs 结构体 - 全局: 各子命令 after_help 补充典型值示例 - output.rs: OutputMode 枚举 + TTY 检测 - 文档: README/AGENTS 面向 AI 的用法,连接串改为 : Made-with: Cursor --- .gitignore | 3 +- AGENTS.md | 217 ++++++++++++++++++++++++++--------- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 137 +++++++++++++++------- src/commands/add.rs | 88 ++++++++++----- src/commands/search.rs | 250 ++++++++++++++++++++++++++++++++++++----- src/main.rs | 234 ++++++++++++++++++++++++++++++++------ src/output.rs | 47 ++++++++ 9 files changed, 791 insertions(+), 189 deletions(-) create mode 100644 src/output.rs diff --git a/.gitignore b/.gitignore index 4235af7..d1fc889 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .env -.DS_Store \ No newline at end of file +.DS_Store +.cursor/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index e70ae54..2a90f99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,15 @@ secrets/ src/ main.rs # CLI 入口,clap 命令定义,auto-migrate,--verbose 全局参数 + output.rs # OutputMode 枚举 + TTY 检测(TTY→text,非 TTY→json-compact) config.rs # 配置读写:~/.config/secrets/config.toml(database_url) db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log) models.rs # Secret 结构体(sqlx::FromRow + serde) audit.rs # 审计写入:向 audit_log 表记录所有写操作 commands/ - add.rs # add 命令:upsert,支持 --meta key=value / --secret key=@file + add.rs # add 命令:upsert,支持 --meta key=value / --secret key=@file / -o json config.rs # config 命令:set-db / show / path(持久化 database_url) - search.rs # search 命令:多条件动态查询 + search.rs # search 命令:多条件查询,-f/-o/--summary/--limit/--offset/--sort delete.rs # delete 命令 update.rs # update 命令:增量更新(合并 tags/metadata/encrypted) scripts/ @@ -27,9 +28,9 @@ secrets/ ## 数据库 -- **Host**: `47.117.131.22:5432`(阿里云上海 ECS,PostgreSQL 18 with io_uring) +- **Host**: `:` - **Database**: `secrets` -- **连接串**: `postgres://postgres:@47.117.131.22:5432/secrets` +- **连接串**: `postgres://postgres:@:/secrets` - **表**: `secrets`(主表)+ `audit_log`(审计表),首次连接自动建表(auto-migrate) ### 表结构 @@ -80,8 +81,8 @@ audit_log ( 首次使用需显式配置数据库连接,设置一次后在该设备上持久生效: ```bash -secrets config set-db "postgres://postgres:@47.117.131.22:5432/secrets" -secrets config show # 查看当前配置(密码脱敏) +secrets config set-db "postgres://postgres:@:/secrets" +secrets config show # 查看当前配置(密码脱敏) secrets config path # 打印配置文件路径 ``` @@ -89,50 +90,90 @@ secrets config path # 打印配置文件路径 ## CLI 命令 +### AI 使用主路径 + +**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。** + +输出格式规则: +- TTY(终端直接运行)→ 默认 `text` +- 非 TTY(管道/重定向/AI 调用)→ 自动 `json-compact` +- 显式 `-o json` → 美化 JSON +- 显式 `-o env` → KEY=VALUE(可 source) + +--- + +### search — 发现与读取 + ```bash -# 查看版本 -secrets -V / --version +# 参数说明(带典型值) +# -n / --namespace refining | ricnsmart +# --kind server | service +# --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt +# --tag aliyun | hongkong | production +# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata) +# --show-secrets 不带值的 flag,显示 encrypted 字段内容 +# -f / --field metadata.ip | metadata.url | secret.token | secret.ssh_key +# --summary 不带值的 flag,仅返回摘要(name/tags/desc/updated_at) +# --limit 20 | 50(默认 50) +# --offset 0 | 10 | 20(分页偏移) +# --sort name(默认)| updated | created +# -o / --output text | json | json-compact | env -# 查看帮助 -secrets -h / --help -secrets help # 子命令详细帮助,如 secrets help add +# 发现概览(起步推荐) +secrets search --summary --limit 20 +secrets search -n refining --summary --limit 20 +secrets search --sort updated --limit 10 --summary -# 添加或更新记录(upsert) -secrets add -n --kind --name \ - [--tag ]... # 可重复 - [-m key=value]... # --meta 明文字段,-m 是短标志 - [-s key=value]... # --secret 敏感字段,value 以 @ 开头表示从文件读取 +# 精确定位单条记录 +secrets search -n refining --kind service --name gitea +secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc -# 搜索(默认隐藏 encrypted 内容) -secrets search [-n ] [--kind ] [--tag ] [-q ] [--show-secrets] -# -q 匹配范围:name、namespace、kind、metadata 全文内容、tags +# 精确定位并获取完整内容(含 secrets) +secrets search -n refining --kind service --name gitea -o json --show-secrets -# 开启 debug 级别日志(全局参数,位于子命令之前) -secrets --verbose -secrets -v -# 或通过环境变量控制:RUST_LOG=secrets=trace secrets search +# 直接提取字段值(最短路径,-f secret.* 自动解锁 secrets) +secrets search -n refining --kind service --name gitea -f secret.token +secrets search -n refining --kind service --name gitea -f metadata.url +secrets search -n refining --kind service --name gitea \ + -f metadata.url -f metadata.default_org -f secret.token -# 增量更新已有记录(合并语义,记录不存在则报错) -secrets update -n --kind --name \ - [--add-tag ]... # 添加标签(不影响已有标签) - [--remove-tag ]... # 移除标签 - [-m key=value]... # 新增或覆盖 metadata 字段(不影响其他字段) - [--remove-meta ]... # 删除 metadata 字段 - [-s key=value]... # 新增或覆盖 encrypted 字段(不影响其他字段) - [--remove-secret ]... # 删除 encrypted 字段 +# 模糊关键词搜索 +secrets search -q mqtt +secrets search -q grafana +secrets search -q 47.117 -# 删除 -secrets delete -n --kind --name +# 按条件过滤 +secrets search -n refining --kind service +secrets search -n ricnsmart --kind server +secrets search --tag hongkong +secrets search --tag aliyun --summary -# 配置(持久化 database_url,设置一次即可) -secrets config set-db -secrets config show -secrets config path +# 分页 +secrets search -n refining --summary --limit 10 --offset 0 +secrets search -n refining --summary --limit 10 --offset 10 + +# 管道 / AI 调用(非 TTY 自动 json-compact) +secrets search -n refining --kind service | jq '.[].name' +secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token' + +# 导出为 env 文件(单条记录) +secrets search -n refining --kind service --name gitea -o env --show-secrets \ + > ~/.config/gitea/config.env ``` -### 示例 +--- + +### add — 新增或全量覆盖(upsert) ```bash +# 参数说明(带典型值) +# -n / --namespace refining | ricnsmart +# --kind server | service +# --name gitea | i-uf63f2uookgs5uxmrdyc +# --tag aliyun | hongkong(可重复) +# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://...(可重复) +# -s / --secret token= | ssh_key=@./key.pem | password=secret123(可重复) + # 添加服务器 secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ --tag aliyun --tag shanghai \ @@ -142,30 +183,101 @@ secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ # 添加服务凭据 secrets add -n refining --kind service --name gitea \ --tag gitea \ - -m url=https://gitea.refining.dev \ - -s token= + -m url=https://gitea.refining.dev -m default_org=refining -m username=voson \ + -s token= -s runner_token= -# 搜索含 mqtt 的所有记录 -secrets search -q mqtt +# 从文件读取 token +secrets add -n ricnsmart --kind service --name mqtt \ + -m host=mqtt.ricnsmart.com -m port=1883 \ + -s password=@./mqtt_password.txt +``` -# 查看 refining 的全部服务配置(显示 secrets) -secrets search -n refining --kind service --show-secrets +--- -# 按 tag 筛选 -secrets search --tag hongkong +### update — 增量更新(记录必须已存在) -# 只更新一个 IP(不影响其他 metadata/secrets/tags) +只有传入的字段才会变动,其余全部保留。 + +```bash +# 参数说明(带典型值) +# -n / --namespace refining | ricnsmart +# --kind server | service +# --name gitea | i-uf63f2uookgs5uxmrdyc +# --add-tag production | backup(不影响已有 tag,可重复) +# --remove-tag staging | deprecated(可重复) +# -m / --meta ip=10.0.0.1 | desc="新描述"(新增或覆盖,可重复) +# --remove-meta old_port | legacy_key(删除 metadata 字段,可重复) +# -s / --secret token= | ssh_key=@./new.pem(新增或覆盖,可重复) +# --remove-secret old_password | deprecated_key(删除 secret 字段,可重复) + +# 更新单个 metadata 字段 secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ -m ip=10.0.0.1 -# 给一条记录新增 tag 并轮换密码 +# 轮换 token +secrets update -n refining --kind service --name gitea \ + -s token= + +# 新增 tag 并轮换 token secrets update -n refining --kind service --name gitea \ --add-tag production \ -s token= -# 移除一个废弃的 metadata 字段 +# 移除废弃字段 secrets update -n refining --kind service --name mqtt \ - --remove-meta old_port + --remove-meta old_port --remove-secret old_password + +# 移除 tag +secrets update -n refining --kind service --name gitea --remove-tag staging +``` + +--- + +### delete — 删除记录 + +```bash +# 参数说明(带典型值) +# -n / --namespace refining | ricnsmart +# --kind server | service +# --name gitea | i-uf63f2uookgs5uxmrdyc(必须精确匹配) + +# 删除服务凭据 +secrets delete -n refining --kind service --name legacy-mqtt + +# 删除服务器记录 +secrets delete -n ricnsmart --kind server --name i-old-server-id +``` + +--- + +### config — 配置管理 + +```bash +# 设置数据库连接(每台设备执行一次,之后永久生效) +secrets config set-db "postgres://postgres:@:/secrets" + +# 查看当前配置(密码脱敏) +secrets config show + +# 打印配置文件路径 +secrets config path +# 输出: /Users//.config/secrets/config.toml +``` + +--- + +### 全局参数 + +```bash +# debug 日志(位于子命令之前) +secrets --verbose search -q mqtt +secrets -v add -n refining --kind service --name gitea -m url=xxx -s token=yyy + +# 或通过环境变量精细控制 +RUST_LOG=secrets=trace secrets search + +# 一次性覆盖数据库连接 +secrets --db-url "postgres://..." search -n refining ``` ## 代码规范 @@ -174,9 +286,10 @@ secrets update -n refining --kind service --name mqtt \ - 异步:全程 `tokio`,数据库操作 `sqlx` async - SQL:使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2`) - 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码 -- 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose +- 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose,`-f`=field,`-o`=output - 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!` - 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断 +- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json` ## 提交前检查(必须全部通过) @@ -194,7 +307,7 @@ grep '^version' Cargo.toml git tag -l 'secrets-*' ``` -若当前版本已被 tag(例如已有 `secrets-0.1.0` 且 `Cargo.toml` 仍为 `0.1.0`),则应在 `Cargo.toml` 中 bump 版本号后再提交,以便 CI 自动打新 Tag 并发布 Release。 +若当前版本已被 tag(例如已有 `secrets-0.3.0` 且 `Cargo.toml` 仍为 `0.3.0`),则应在 `Cargo.toml` 中 bump 版本号后再提交,以便 CI 自动打新 Tag 并发布 Release。 ### 2. 格式、Lint、测试 diff --git a/Cargo.lock b/Cargo.lock index 05b300c..9a5a195 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,7 +1245,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7089151..7be41d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.3.0" +version = "0.4.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 06feea7..46e6866 100644 --- a/README.md +++ b/README.md @@ -14,60 +14,116 @@ cargo build --release 配置数据库连接(首次使用需执行一次,之后在该设备上持久生效): ```bash -secrets config set-db "postgres://postgres:@:5432/secrets" +secrets config set-db "postgres://postgres:@:/secrets" ``` -## 使用 +## AI Agent 快速指南 + +这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。 + +### 第一步:发现有哪些数据 ```bash -# 查看版本 -secrets -V -secrets --version +# 列出所有记录摘要(默认最多 50 条,安全起步) +secrets search --summary --limit 20 -# 查看帮助 +# 按 namespace 过滤 +secrets search -n refining --summary --limit 20 + +# 按最近更新排序 +secrets search --sort updated --limit 10 --summary +``` + +`--summary` 只返回轻量字段(namespace、kind、name、tags、desc、updated_at),不含完整 metadata 和 secrets。 + +### 第二步:精确读取单条记录 + +```bash +# 精确定位(namespace + kind + name 三元组) +secrets search -n refining --kind service --name gitea + +# 获取完整记录含 secrets(JSON 格式,AI 最易解析) +secrets search -n refining --kind service --name gitea -o json --show-secrets + +# 直接提取单个字段值(最短路径) +secrets search -n refining --kind service --name gitea -f secret.token +secrets search -n refining --kind service --name gitea -f metadata.url + +# 同时提取多个字段 +secrets search -n refining --kind service --name gitea \ + -f metadata.url -f metadata.default_org -f secret.token +``` + +`-f secret.*` 会自动解锁 secrets,无需额外加 `--show-secrets`。 + +### 输出格式 + +| 场景 | 推荐命令 | +|------|----------| +| AI 解析 / 管道处理 | `-o json` 或 `-o json-compact` | +| 写入 `.env` 文件 | `-o env --show-secrets` | +| 人类查看 | 默认 `text`(TTY 下自动启用) | +| 非 TTY(管道/重定向) | 自动 `json-compact` | + +```bash +# 管道直接 jq 解析(非 TTY 自动 json-compact) +secrets search -n refining --kind service | jq '.[].name' +secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token' + +# 导出为可 source 的 env 文件(单条记录) +secrets search -n refining --kind service --name gitea -o env --show-secrets \ + > ~/.config/gitea/config.env +``` + +## 完整命令参考 + +```bash +# 查看帮助(包含各子命令 EXAMPLES) secrets --help -secrets -h +secrets search --help +secrets add --help +secrets update --help +secrets delete --help +secrets config --help -# 查看子命令帮助 -secrets help config -secrets help add -secrets help search -secrets help delete -secrets help update +# ── search ────────────────────────────────────────────────────────────────── +secrets search --summary --limit 20 # 发现概览 +secrets search -n refining --kind service # 按 namespace + kind +secrets search -n refining --kind service --name gitea # 精确查找 +secrets search -q mqtt # 关键词模糊搜索 +secrets search --tag hongkong # 按 tag 过滤 +secrets search -n refining --kind service --name gitea -f secret.token # 提取字段 +secrets search -n refining --kind service --name gitea -o json --show-secrets # 完整 JSON +secrets search --sort updated --limit 10 --summary # 最近改动 +secrets search -n refining --summary --limit 10 --offset 10 # 翻页 -# 添加服务器 +# ── add ────────────────────────────────────────────────────────────────────── secrets add -n refining --kind server --name my-server \ --tag aliyun --tag shanghai \ - -m ip=1.2.3.4 -m desc="My Server" \ - -s username=root \ - -s ssh_key=@./keys/my.pem + -m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \ + -s username=root -s ssh_key=@./keys/server.pem -# 添加服务凭据 secrets add -n refining --kind service --name gitea \ - -m url=https://gitea.example.com \ + --tag gitea \ + -m url=https://gitea.refining.dev -m default_org=refining \ -s token= -# 搜索(默认隐藏敏感字段) -secrets search -secrets search -n refining --kind server -secrets search --tag hongkong -secrets search -q mqtt # 关键词匹配 name / metadata / tags -secrets search -n refining --kind service --name gitea --show-secrets - -# 开启 debug 级别日志(--verbose / -v,全局参数) -secrets --verbose search -q mqtt -secrets -v add -n refining --kind service --name gitea -m url=xxx -s token=yyy - -# 或通过环境变量精细控制日志级别 -RUST_LOG=secrets=trace secrets search - -# 增量更新已有记录(合并语义,记录不存在则报错) +# ── update ─────────────────────────────────────────────────────────────────── secrets update -n refining --kind server --name my-server -m ip=10.0.0.1 -secrets update -n refining --kind service --name gitea --add-tag production -s token= +secrets update -n refining --kind service --name gitea --add-tag production -s token= secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key -# 删除 -secrets delete -n refining --kind server --name my-server +# ── delete ─────────────────────────────────────────────────────────────────── +secrets delete -n refining --kind service --name legacy-mqtt + +# ── config ─────────────────────────────────────────────────────────────────── +secrets config set-db "postgres://postgres:@:/secrets" +secrets config show # 密码脱敏展示 +secrets config path # 打印配置文件路径 + +# ── 调试 ────────────────────────────────────────────────────────────────────── +secrets --verbose search -q mqtt +RUST_LOG=secrets=trace secrets search ``` ## 数据模型 @@ -101,15 +157,16 @@ LIMIT 20; ``` src/ - main.rs # CLI 入口(clap) + main.rs # CLI 入口(clap),含各子命令 after_help 示例 + output.rs # OutputMode 枚举 + TTY 检测 config.rs # 配置读写(~/.config/secrets/config.toml) db.rs # 连接池 + auto-migrate(secrets + audit_log) models.rs # Secret 结构体 audit.rs # 审计日志写入(audit_log 表) commands/ - add.rs # upsert + add.rs # upsert,支持 -o json config.rs # config set-db/show/path - search.rs # 多条件查询 + search.rs # 多条件查询,支持 -f/-o/--summary/--limit/--offset/--sort delete.rs # 删除 update.rs # 增量更新(合并 tags/metadata/encrypted) scripts/ diff --git a/src/commands/add.rs b/src/commands/add.rs index 57cc681..e42dd4d 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -3,6 +3,8 @@ use serde_json::{Map, Value, json}; use sqlx::PgPool; use std::fs; +use crate::output::OutputMode; + /// Parse "key=value" entries. Value starting with '@' reads from file. pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> { let (key, raw_val) = entry.split_once('=').ok_or_else(|| { @@ -31,19 +33,21 @@ fn build_json(entries: &[String]) -> Result { Ok(Value::Object(map)) } -pub async fn run( - pool: &PgPool, - namespace: &str, - kind: &str, - name: &str, - tags: &[String], - meta_entries: &[String], - secret_entries: &[String], -) -> Result<()> { - let metadata = build_json(meta_entries)?; - let encrypted = build_json(secret_entries)?; +pub struct AddArgs<'a> { + pub namespace: &'a str, + pub kind: &'a str, + pub name: &'a str, + pub tags: &'a [String], + pub meta_entries: &'a [String], + pub secret_entries: &'a [String], + pub output: OutputMode, +} - tracing::debug!(namespace, kind, name, "upserting record"); +pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> { + let metadata = build_json(args.meta_entries)?; + let encrypted = build_json(args.secret_entries)?; + + tracing::debug!(args.namespace, args.kind, args.name, "upserting record"); sqlx::query( r#" @@ -57,20 +61,22 @@ pub async fn run( updated_at = NOW() "#, ) - .bind(namespace) - .bind(kind) - .bind(name) - .bind(tags) + .bind(args.namespace) + .bind(args.kind) + .bind(args.name) + .bind(args.tags) .bind(&metadata) .bind(&encrypted) .execute(pool) .await?; - let meta_keys: Vec<&str> = meta_entries + let meta_keys: Vec<&str> = args + .meta_entries .iter() .filter_map(|s| s.split_once('=').map(|(k, _)| k)) .collect(); - let secret_keys: Vec<&str> = secret_entries + let secret_keys: Vec<&str> = args + .secret_entries .iter() .filter_map(|s| s.split_once('=').map(|(k, _)| k)) .collect(); @@ -78,26 +84,46 @@ pub async fn run( crate::audit::log( pool, "add", - namespace, - kind, - name, + args.namespace, + args.kind, + args.name, json!({ - "tags": tags, + "tags": args.tags, "meta_keys": meta_keys, "secret_keys": secret_keys, }), ) .await; - println!("Added: [{}/{}] {}", namespace, kind, name); - if !tags.is_empty() { - println!(" tags: {}", tags.join(", ")); - } - if !meta_entries.is_empty() { - println!(" metadata: {}", meta_keys.join(", ")); - } - if !secret_entries.is_empty() { - println!(" secrets: {}", secret_keys.join(", ")); + let result_json = json!({ + "action": "added", + "namespace": args.namespace, + "kind": args.kind, + "name": args.name, + "tags": args.tags, + "meta_keys": meta_keys, + "secret_keys": secret_keys, + }); + + match args.output { + OutputMode::Json => { + println!("{}", serde_json::to_string_pretty(&result_json)?); + } + OutputMode::JsonCompact => { + println!("{}", serde_json::to_string(&result_json)?); + } + _ => { + println!("Added: [{}/{}] {}", args.namespace, args.kind, args.name); + if !args.tags.is_empty() { + println!(" tags: {}", args.tags.join(", ")); + } + if !args.meta_entries.is_empty() { + println!(" metadata: {}", meta_keys.join(", ")); + } + if !args.secret_entries.is_empty() { + println!(" secrets: {}", secret_keys.join(", ")); + } + } } Ok(()) diff --git a/src/commands/search.rs b/src/commands/search.rs index d764361..e83b880 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -1,36 +1,51 @@ use anyhow::Result; +use serde_json::{Value, json}; use sqlx::PgPool; use crate::models::Secret; +use crate::output::OutputMode; -pub async fn run( - pool: &PgPool, - namespace: Option<&str>, - kind: Option<&str>, - tag: Option<&str>, - query: Option<&str>, - show_secrets: bool, -) -> Result<()> { +pub struct SearchArgs<'a> { + pub namespace: Option<&'a str>, + pub kind: Option<&'a str>, + pub name: Option<&'a str>, + pub tag: Option<&'a str>, + pub query: Option<&'a str>, + pub show_secrets: bool, + pub fields: &'a [String], + pub summary: bool, + pub limit: u32, + pub offset: u32, + pub sort: &'a str, + pub output: OutputMode, +} + +pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> { let mut conditions: Vec = Vec::new(); let mut idx: i32 = 1; - if namespace.is_some() { + if args.namespace.is_some() { conditions.push(format!("namespace = ${}", idx)); idx += 1; } - if kind.is_some() { + if args.kind.is_some() { conditions.push(format!("kind = ${}", idx)); idx += 1; } - if tag.is_some() { + if args.name.is_some() { + conditions.push(format!("name = ${}", idx)); + idx += 1; + } + if args.tag.is_some() { conditions.push(format!("tags @> ARRAY[${}]", idx)); idx += 1; } - if query.is_some() { + if args.query.is_some() { conditions.push(format!( "(name ILIKE ${i} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))", i = idx )); + idx += 1; } let where_clause = if conditions.is_empty() { @@ -39,49 +54,166 @@ pub async fn run( format!("WHERE {}", conditions.join(" AND ")) }; + let order = match args.sort { + "updated" => "updated_at DESC", + "created" => "created_at DESC", + _ => "namespace, kind, name", + }; + let sql = format!( - "SELECT * FROM secrets {} ORDER BY namespace, kind, name", - where_clause + "SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}", + where_clause, + order, + idx, + idx + 1 ); tracing::debug!(sql, "executing search query"); let mut q = sqlx::query_as::<_, Secret>(&sql); - if let Some(v) = namespace { + if let Some(v) = args.namespace { q = q.bind(v); } - if let Some(v) = kind { + if let Some(v) = args.kind { q = q.bind(v); } - if let Some(v) = tag { + if let Some(v) = args.name { q = q.bind(v); } - if let Some(v) = query { + if let Some(v) = args.tag { + q = q.bind(v); + } + if let Some(v) = args.query { q = q.bind(format!("%{}%", v)); } + q = q.bind(args.limit as i64).bind(args.offset as i64); let rows = q.fetch_all(pool).await?; - if rows.is_empty() { - println!("No records found."); - return Ok(()); + // -f/--field: extract specific field values directly + if !args.fields.is_empty() { + return print_fields(&rows, args.fields); } - for row in &rows { - println!("[{}/{}] {}", row.namespace, row.kind, row.name,); - println!(" id: {}", row.id); + match args.output { + OutputMode::Json | OutputMode::JsonCompact => { + let arr: Vec = rows + .iter() + .map(|r| to_json(r, args.show_secrets, args.summary)) + .collect(); + let out = if args.output == OutputMode::Json { + serde_json::to_string_pretty(&arr)? + } else { + serde_json::to_string(&arr)? + }; + println!("{}", out); + } + OutputMode::Env => { + if rows.len() > 1 { + anyhow::bail!( + "env output requires exactly one record; got {}. Add more filters.", + rows.len() + ); + } + if let Some(row) = rows.first() { + print_env(row, args.show_secrets)?; + } else { + eprintln!("No records found."); + } + } + OutputMode::Text => { + if rows.is_empty() { + println!("No records found."); + return Ok(()); + } + for row in &rows { + print_text(row, args.show_secrets, args.summary)?; + } + println!("{} record(s) found.", rows.len()); + if rows.len() == args.limit as usize { + println!( + " (showing up to {}; use --offset {} to see more)", + args.limit, + args.offset + args.limit + ); + } + } + } + Ok(()) +} + +fn to_json(row: &Secret, show_secrets: bool, summary: bool) -> Value { + if summary { + let desc = row + .metadata + .get("desc") + .or_else(|| row.metadata.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + return json!({ + "namespace": row.namespace, + "kind": row.kind, + "name": row.name, + "tags": row.tags, + "desc": desc, + "updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + }); + } + + let secrets_val = if show_secrets { + row.encrypted.clone() + } else { + let keys: Vec<&str> = row + .encrypted + .as_object() + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + json!({"_hidden_keys": keys}) + }; + + json!({ + "id": row.id, + "namespace": row.namespace, + "kind": row.kind, + "name": row.name, + "tags": row.tags, + "metadata": row.metadata, + "secrets": secrets_val, + "created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + "updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + }) +} + +fn print_text(row: &Secret, show_secrets: bool, summary: bool) -> Result<()> { + println!("[{}/{}] {}", row.namespace, row.kind, row.name); + if summary { + let desc = row + .metadata + .get("desc") + .or_else(|| row.metadata.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("-"); + if !row.tags.is_empty() { + println!(" tags: [{}]", row.tags.join(", ")); + } + println!(" desc: {}", desc); + println!( + " updated: {}", + row.updated_at.format("%Y-%m-%d %H:%M:%S UTC") + ); + } else { + println!(" id: {}", row.id); if !row.tags.is_empty() { println!(" tags: [{}]", row.tags.join(", ")); } - if row.metadata.as_object().is_some_and(|m| !m.is_empty()) { println!( " metadata: {}", serde_json::to_string_pretty(&row.metadata)? ); } - if show_secrets { println!( " secrets: {}", @@ -100,13 +232,73 @@ pub async fn run( ); } } - println!( " created: {}", row.created_at.format("%Y-%m-%d %H:%M:%S UTC") ); - println!(); } - println!("{} record(s) found.", rows.len()); + println!(); Ok(()) } + +fn print_env(row: &Secret, show_secrets: bool) -> Result<()> { + let prefix = row.name.to_uppercase().replace(['-', '.'], "_"); + if let Some(meta) = row.metadata.as_object() { + for (k, v) in meta { + let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_")); + println!("{}={}", key, v.as_str().unwrap_or(&v.to_string())); + } + } + if show_secrets && let Some(enc) = row.encrypted.as_object() { + for (k, v) in enc { + let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_")); + println!("{}={}", key, v.as_str().unwrap_or(&v.to_string())); + } + } + Ok(()) +} + +/// Extract one or more field paths like `metadata.url` or `secret.token`. +fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> { + for row in rows { + for field in fields { + let val = extract_field(row, field)?; + println!("{}", val); + } + } + Ok(()) +} + +fn extract_field(row: &Secret, field: &str) -> Result { + let (section, key) = field.split_once('.').ok_or_else(|| { + anyhow::anyhow!( + "Invalid field path '{}'. Use metadata. or secret.", + field + ) + })?; + + let obj = match section { + "metadata" | "meta" => &row.metadata, + "secret" | "secrets" | "encrypted" => &row.encrypted, + other => anyhow::bail!( + "Unknown field section '{}'. Use 'metadata' or 'secret'", + other + ), + }; + + obj.get(key) + .and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| Some(v.to_string())) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "Field '{}' not found in record [{}/{}/{}]", + field, + row.namespace, + row.kind, + row.name + ) + }) +} diff --git a/src/main.rs b/src/main.rs index e860f25..f5da04b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,16 +3,31 @@ mod commands; mod config; mod db; mod models; +mod output; use anyhow::Result; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; +use output::resolve_output_mode; + #[derive(Parser)] #[command( name = "secrets", version, - about = "Secrets & config manager backed by PostgreSQL" + about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents", + after_help = "QUICK START (AI agents): + # Discover what namespaces / kinds exist + secrets search --summary --limit 20 + + # Precise lookup (JSON output for easy parsing) + secrets search -n refining --kind service --name gitea -o json --show-secrets + + # Extract a single field value directly + secrets search -n refining --kind service --name gitea -f secret.token + + # Pipe-friendly (non-TTY defaults to json-compact automatically) + secrets search -n refining --kind service | jq '.[].name'" )] struct Cli { /// Database URL, overrides saved config (one-time override) @@ -29,72 +44,181 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Add or update a record (upsert) + /// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets. + #[command(after_help = "EXAMPLES: + # Add a server + secrets add -n refining --kind server --name my-server \\ + --tag aliyun --tag shanghai \\ + -m ip=47.117.131.22 -m desc=\"Aliyun Shanghai ECS\" \\ + -s username=root -s ssh_key=@./keys/server.pem + + # Add a service credential + secrets add -n refining --kind service --name gitea \\ + --tag gitea \\ + -m url=https://gitea.refining.dev -m default_org=refining \\ + -s token= + + # Add with token read from a file + secrets add -n ricnsmart --kind service --name mqtt \\ + -m host=mqtt.ricnsmart.com -m port=1883 \\ + -s password=@./mqtt_password.txt")] Add { - /// Namespace (e.g. refining, ricnsmart) + /// Namespace, e.g. refining, ricnsmart #[arg(short, long)] namespace: String, - /// Kind of record (server, service, key, ...) + /// Kind of record: server, service, key, ... #[arg(long)] kind: String, - /// Human-readable name + /// Human-readable unique name, e.g. gitea, i-uf63f2uookgs5uxmrdyc #[arg(long)] name: String, - /// Tags for categorization (repeatable) + /// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong #[arg(long = "tag")] tags: Vec, - /// Plaintext metadata entry: key=value (repeatable, key=@file reads from file) + /// Plaintext metadata: key=value (repeatable; value=@file reads from file) #[arg(long = "meta", short = 'm')] meta: Vec, - /// Secret entry: key=value (repeatable, key=@file reads from file) + /// Secret entry: key=value (repeatable; value=@file reads from file) #[arg(long = "secret", short = 's')] secrets: Vec, + /// Output format: text (default on TTY), json, json-compact, env + #[arg(short, long = "output")] + output: Option, }, - /// Search records + /// Search / read records. This is the primary read command for AI agents. + /// + /// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f), + /// summary view (--summary), pagination (--limit / --offset), and structured + /// output (-o json / json-compact / env). When stdout is not a TTY, output + /// defaults to json-compact automatically. + #[command(after_help = "EXAMPLES: + # Discover all records (summary, safe default limit) + secrets search --summary --limit 20 + + # Filter by namespace and kind + secrets search -n refining --kind service + + # Exact lookup — returns 0 or 1 record + secrets search -n refining --kind service --name gitea + + # Fuzzy keyword search (matches name, namespace, kind, tags, metadata) + secrets search -q mqtt + + # Extract a single field value (implies --show-secrets for secret.*) + secrets search -n refining --kind service --name gitea -f secret.token + secrets search -n refining --kind service --name gitea -f metadata.url + + # Multiple fields at once + secrets search -n refining --kind service --name gitea \\ + -f metadata.url -f metadata.default_org -f secret.token + + # Full JSON output with secrets revealed (ideal for AI parsing) + secrets search -n refining --kind service --name gitea -o json --show-secrets + + # Export as env vars (source-able; single record only) + secrets search -n refining --kind service --name gitea -o env --show-secrets + + # Paginate large result sets + secrets search -n refining --summary --limit 10 --offset 0 + secrets search -n refining --summary --limit 10 --offset 10 + + # Sort by most recently updated + secrets search --sort updated --limit 5 --summary + + # Non-TTY / pipe: output is json-compact by default + secrets search -n refining --kind service | jq '.[].name' + secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'")] Search { - /// Filter by namespace + /// Filter by namespace, e.g. refining, ricnsmart #[arg(short, long)] namespace: Option, - /// Filter by kind + /// Filter by kind, e.g. server, service #[arg(long)] kind: Option, - /// Filter by tag + /// Exact name filter, e.g. gitea, i-uf63f2uookgs5uxmrdyc + #[arg(long)] + name: Option, + /// Filter by tag, e.g. --tag aliyun #[arg(long)] tag: Option, - /// Search by keyword (matches name, namespace, kind) + /// Fuzzy keyword (matches name, namespace, kind, tags, metadata text) #[arg(short, long)] query: Option, - /// Reveal encrypted secret values + /// Reveal encrypted secret values in output #[arg(long)] show_secrets: bool, + /// Extract field value(s) directly: metadata. or secret. (repeatable) + #[arg(short = 'f', long = "field")] + fields: Vec, + /// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at) + #[arg(long)] + summary: bool, + /// Maximum number of records to return [default: 50] + #[arg(long, default_value = "50")] + limit: u32, + /// Skip this many records (for pagination) + #[arg(long, default_value = "0")] + offset: u32, + /// Sort order: name (default), updated, created + #[arg(long, default_value = "name")] + sort: String, + /// Output format: text (default on TTY), json, json-compact, env + #[arg(short, long = "output")] + output: Option, }, - /// Delete a record + /// Delete a record permanently. Requires exact namespace + kind + name. + #[command(after_help = "EXAMPLES: + # Delete a service credential + secrets delete -n refining --kind service --name legacy-mqtt + + # Delete a server record + secrets delete -n ricnsmart --kind server --name i-old-server-id")] Delete { - /// Namespace + /// Namespace, e.g. refining #[arg(short, long)] namespace: String, - /// Kind + /// Kind, e.g. server, service #[arg(long)] kind: String, - /// Name + /// Exact name of the record to delete #[arg(long)] name: String, }, - /// Incrementally update an existing record (merge semantics) + /// Incrementally update an existing record (merge semantics; record must exist). + /// + /// Only the fields you pass are changed — everything else is preserved. + /// Use --add-tag / --remove-tag to modify tags without touching other fields. + #[command(after_help = "EXAMPLES: + # Update a single metadata field (all other fields unchanged) + secrets update -n refining --kind server --name my-server -m ip=10.0.0.1 + + # Rotate a secret token + secrets update -n refining --kind service --name gitea -s token= + + # Add a tag and rotate password at the same time + secrets update -n refining --kind service --name gitea \\ + --add-tag production -s token= + + # Remove a deprecated metadata field and a stale secret key + secrets update -n refining --kind service --name mqtt \\ + --remove-meta old_port --remove-secret old_password + + # Remove a tag + secrets update -n refining --kind service --name gitea --remove-tag staging")] Update { - /// Namespace (e.g. refining, ricnsmart) + /// Namespace, e.g. refining, ricnsmart #[arg(short, long)] namespace: String, - /// Kind of record (server, service, key, ...) + /// Kind of record: server, service, key, ... #[arg(long)] kind: String, - /// Human-readable name + /// Human-readable unique name #[arg(long)] name: String, - /// Add a tag (repeatable) + /// Add a tag (repeatable; does not affect existing tags) #[arg(long = "add-tag")] add_tags: Vec, /// Remove a tag (repeatable) @@ -103,18 +227,27 @@ enum Commands { /// Set or overwrite a metadata field: key=value (repeatable, @file supported) #[arg(long = "meta", short = 'm')] meta: Vec, - /// Remove a metadata field by key (repeatable) + /// Delete a metadata field by key (repeatable) #[arg(long = "remove-meta")] remove_meta: Vec, /// Set or overwrite a secret field: key=value (repeatable, @file supported) #[arg(long = "secret", short = 's')] secrets: Vec, - /// Remove a secret field by key (repeatable) + /// Delete a secret field by key (repeatable) #[arg(long = "remove-secret")] remove_secrets: Vec, }, /// Manage CLI configuration (database connection, etc.) + #[command(after_help = "EXAMPLES: + # Configure the database URL (run once per device; persisted to config file) + secrets config set-db \"postgres://postgres:@:/secrets\" + + # Show current config (password is masked) + secrets config show + + # Print path to the config file + secrets config path")] Config { #[command(subcommand)] action: ConfigAction, @@ -125,12 +258,12 @@ enum Commands { enum ConfigAction { /// Save database URL to config file (~/.config/secrets/config.toml) SetDb { - /// PostgreSQL connection string + /// PostgreSQL connection string, e.g. postgres://user:pass@:/dbname url: String, }, - /// Show current configuration + /// Show current configuration (password masked) Show, - /// Print path to config file + /// Print path to the config file Path, } @@ -172,26 +305,59 @@ async fn main() -> Result<()> { tags, meta, secrets, + output, } => { let _span = tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered(); - commands::add::run(&pool, namespace, kind, name, tags, meta, secrets).await?; + let out = resolve_output_mode(output.as_deref())?; + commands::add::run( + &pool, + commands::add::AddArgs { + namespace, + kind, + name, + tags, + meta_entries: meta, + secret_entries: secrets, + output: out, + }, + ) + .await?; } Commands::Search { namespace, kind, + name, tag, query, show_secrets, + fields, + summary, + limit, + offset, + sort, + output, } => { let _span = tracing::info_span!("cmd", command = "search").entered(); + // -f implies --show-secrets when any field path starts with "secret" + let show = *show_secrets || fields.iter().any(|f| f.starts_with("secret")); + let out = resolve_output_mode(output.as_deref())?; commands::search::run( &pool, - namespace.as_deref(), - kind.as_deref(), - tag.as_deref(), - query.as_deref(), - *show_secrets, + commands::search::SearchArgs { + namespace: namespace.as_deref(), + kind: kind.as_deref(), + name: name.as_deref(), + tag: tag.as_deref(), + query: query.as_deref(), + show_secrets: show, + fields, + summary: *summary, + limit: *limit, + offset: *offset, + sort, + output: out, + }, ) .await?; } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..b9d2709 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,47 @@ +use std::io::IsTerminal; +use std::str::FromStr; + +/// Output format for all commands. +#[derive(Debug, Clone, Default, PartialEq)] +pub enum OutputMode { + /// Human-readable text (default when stdout is a TTY) + #[default] + Text, + /// Pretty-printed JSON + Json, + /// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq) + JsonCompact, + /// KEY=VALUE pairs suitable for `source` or `.env` files + Env, +} + +impl FromStr for OutputMode { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + "json-compact" => Ok(Self::JsonCompact), + "env" => Ok(Self::Env), + other => Err(anyhow::anyhow!( + "Unknown output format '{}'. Valid: text, json, json-compact, env", + other + )), + } + } +} + +/// Resolve the effective output mode. +/// - Explicit value from `--output` takes priority. +/// - TTY → text; non-TTY (piped/redirected) → json-compact. +pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result { + if let Some(s) = explicit { + return s.parse(); + } + if std::io::stdout().is_terminal() { + Ok(OutputMode::Text) + } else { + Ok(OutputMode::JsonCompact) + } +}