@chengzzzi44/dsh-shopify
v0.1.2
Published
Shopify Admin GraphQL tools and a settings card for DeepSeek Harness: 8 read-only queries plus 10 opt-in writes covering the product, inventory, and image workflow.
Maintainers
Readme
@chengzzzi44/dsh-shopify
DeepSeek Harness 的 Shopify 插件:让模型直接读写你店铺的商品、订单、客户和库存,走 Shopify 官方的 Admin GraphQL API。
- 18 个工具:8 个只读 + 10 个写入
- 写入默认关闭:
enableWrites为 false 时,10 个写工具直接拒绝、一个请求都不发;要在设置卡片里打开「允许写入」才生效 - 不碰的东西:订单(发货、取消、退款)、客户、折扣、礼品卡——这些写操作一律没有
- 商品侧闭环:新建草稿 → 上架发布 → 写库存 → 传图/挂图,都在插件里
工具
只读工具(8 个,随时可用)
| 工具 | 作用 | 需要的 Admin scope |
|---|---|---|
| shopify_shop | 店铺名称、myshopify 域名、主域名、币种、时区 | 无 |
| shopify_products | 搜索/列商品(支持 Shopify 查询语法),返回 GID、价格区间、库存、状态、上架时间 | read_products |
| shopify_products_summary | 一次调用给出全店概览:各状态数量、上架但零库存的数量、最新上架与最新草稿 | read_products |
| shopify_product | 单个商品详情:选项、变体(SKU/价格/库存)、SEO、所属系列 | read_products |
| shopify_orders | 搜索/列订单(按支付状态、履约状态、时间、标签等) | read_orders |
| shopify_order | 单个订单:行项目、金额拆分、客户、履约与物流单号 | read_orders |
| shopify_customers | 搜索/列客户:邮箱、订单数、累计消费、标签 | read_customers |
| shopify_inventory | 按 SKU 查各库位的可用/在库/占用/在途数量 | read_inventory + read_locations |
写工具(10 个,默认全部关闭)
| 工具 | 作用 | 需要的 Admin scope |
|---|---|---|
| shopify_product_create | 新建商品,一次带上选项、变体、价格、图片、初始库存;状态默认 DRAFT | write_products(带库存还要 write_inventory) |
| shopify_product_update | 改状态(草稿上架/下架/归档)、标签、标题、供应商、类型,以及按变体改价 | write_products |
| shopify_product_publish | 发布到销售渠道("顾客能看到"的那一步),默认 Online Store | write_products |
| shopify_product_delete | 永久删除一个商品 | write_products |
| shopify_product_image_add | 给商品加一张图——本地文件(先上传)或一个图片 URL | write_products + write_files |
| shopify_file_upload | 本地图片(PNG/JPEG/WebP/GIF,≤20 MB)传进 Shopify Files | write_files |
| shopify_file_delete | 按 GID 删除 Files 里的文件;删商品图对应的文件会同时把图从商品上摘掉 | write_files |
| shopify_inventory_set | 把某 SKU 在某库位的数量设成绝对值 | write_inventory + read_inventory + read_locations |
| shopify_inventory_adjust | 在某库位的现有数量上加减一个带符号的差值 | 同上 |
| shopify_inventory_activate | 让某个变体在某库位首次入库(set 写不了没有库存记录的库位) | 同上 |
三个列表工具(shopify_products / shopify_orders / shopify_customers)都会在第一页附带 totalCount——即当前筛选条件下一共多少条,不用自己翻页数。想看全部就在参数里加 all: true,插件会自己翻页读完(一次最多 1000 行,超了返回 nextCursor 让你接着读):
// 一次读完所有已上架商品,按上架时间从新到旧
{ "query": "status:active", "sortKey": "PUBLISHED_AT", "reverse": true, "all": true }
// → products: 96 of 96shopify_orders 的 status 过滤在 Shopify 那边执行(拼进搜索查询),所以返回的行数和 totalCount 永远是同一批订单。
全店概览一次调用就够:
products: 128 total · 96 active · 32 draft · 0 archived
active out of stock: 3
newest published: 2026-07-30 · Silk Peony Stem … · gid://shopify/Product/1234567890
newest draft: 2026-08-04 · Faux Banana Tree …安装
前置条件:可用的 dsh + PATH 里有 pnpm。
dsh plugin --profile web add @chengzzzi44/dsh-shopify从本仓库源码安装:
git clone https://github.com/chengzzzi44/dsh-shopify.git
cd dsh-shopify && npm install
dsh plugin --profile web add "$PWD"配置
推荐:在「设置 → 插件 → dsh-shopify」卡片里填(店铺域名、client id、client secret 三项)。卡片把域名和 client id 写进该插件的设置节,secret 写进凭据域($DSH_HOME/.credentials.yaml),都不回显密钥;保存后下一次工具调用立即生效,不用重启。
也可以走配置文件,凭据有两种写法,任选,也可以混用(字面值优先于同名的引用):
1. 直接写在插件配置里(配置文件本身就在 ~/.dsh/profiles/<profile>/cordis.patch.yml,不进任何仓库):
- id: dsh-shopify
config:
storeDomain: 'your-store.myshopify.com'
clientId: '你的 client id'
clientSecret: '你的 client secret'
apiVersion: '2026-01'
maxResults: 10旧版静态 token 同理,写 accessToken: 'shpat_...' 即可,此时不会再走 client credentials 换 token。
行里写了
clientSecret字面值就会盖过凭据域里的那份;要在卡片里填 secret,就别在配置文件里留clientSecret。
2. 只写引用名,值放环境/凭据域(适合要提交配置文件的场景):
# ~/.dsh/.env(或启动进程的环境变量)
SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
SHOPIFY_CLIENT_ID=你的 client id
SHOPIFY_CLIENT_SECRET=你的 client secret- id: dsh-shopify
config:
storeDomain: 'your-store.myshopify.com'
clientIdRef: SHOPIFY_CLIENT_ID
clientSecretRef: SHOPIFY_CLIENT_SECRET
apiVersion: '2026-01'
maxResults: 10patch 会整体替换
config,要保留的键都得重写。
| 字段 | 默认 | 说明 |
|---|---|---|
| storeDomain | —(来自 SHOPIFY_STORE_DOMAIN) | 店铺的 *.myshopify.com 域名;未设置时工具仍注册,但每次调用都会明确报错 |
| clientId | — | 直接写在配置里的 client id,优先于 clientIdRef |
| clientSecret | — | 直接写在配置里的 client secret,优先于 clientSecretRef |
| accessToken | — | 直接写在配置里的旧版静态 shpat_ token;设了就不再换 token |
| clientIdRef | SHOPIFY_CLIENT_ID | 凭据引用名(不是密钥本身),clientId 为空时使用 |
| clientSecretRef | SHOPIFY_CLIENT_SECRET | 同上 |
| accessTokenRef | — | 凭据引用名,accessToken 为空时使用 |
| apiVersion | 2026-01 | Admin API 版本,写在 URL 路径里 |
| maxResults | 10 | 列表工具的默认行数,同时是硬上限(最大 50) |
| requestTimeoutMs | 30000 | 单次请求预算,含响应体 |
| enableWrites | false | 是否允许写工具改动店铺;默认关闭,卡片上有开关 |
设置节(命名空间 dsh-shopify)以插件行为基底:卡片里填过的字段进入用户层,没填的继续取 cordis.patch.yml / 环境变量。clientSecret 和 accessToken 标了 role('secret'),任何设置读取都拿不到它们的值。工具每次调用都会重读这份解析结果,所以保存后下一次查询就用新值,不用重启。
凭据怎么来:建一个 Shopify 应用
2026 年起新 app 在 Dev Dashboard 创建,用 client credentials:插件把 client id + secret 换成短期 access token(约 24 小时),缓存在内存里、过期前自动刷新,token 不落盘。
- 打开 https://dev.shopify.com/dashboard → Apps → 新建 app
- 在 Configuration → Admin API access scopes 里勾权限(见下)
- 发布(Release)这个版本——只改不发布不生效
- 到店铺后台 → 设置 → 应用,安装/授权这个 app
- 把 Client ID / Client secret 填进插件设置卡片
只要查询,勾这几个就够(复制即用):
read_products, read_orders, read_customers, read_inventory, read_locations要连写入一起用,再加:
write_products, write_files, write_inventory旧版静态 token 也支持:老 custom app 的 shpat_ token 写到 accessToken / accessTokenRef 即可,此时不再换 token。
scope 改过之后要发布新版本并重新安装,否则换出来的 token 还是旧权限——这是最常见的"明明加了权限却还是 Access denied"的原因。
写入(默认关闭)
上面那 10 个写工具在 enableWrites 为 false 时全部直接拒绝、一个请求都不发,报错里会告诉你去哪打开。打开方式:设置 → 插件 → dsh-shopify 卡片里的「允许写入」,或者在配置里写 enableWrites: true。
打开之后可以:
// 把草稿上架并打标签
{ "id": "gid://shopify/Product/123", "status": "ACTIVE", "tags": ["spring", "sale"] }
// 批量改变体价格
{ "id": "gid://shopify/Product/123", "variants": [{ "id": "gid://shopify/ProductVariant/456", "price": "29.99" }] }几个刻意的设计:标签是整体替换(传什么就是什么,不传就不动);status 只接受 ACTIVE / DRAFT / ARCHIVED;价格必须是十进制字符串;Shopify 返回的 userErrors 会原样抛给模型(而不是假装成功);一次调用里改多个字段只发一条 mutation,价格另发一条。
注意:
status: ACTIVE只改商品状态。要让它在店铺前台可见,还得发布到销售渠道——用shopify_product_publish(默认 Online Store,也可以channels: ["Shop"]指名)。
图片上传
同样受「允许写入」开关控制,关着时一个请求都不发。
// 传本地图到 Shopify Files
{ "path": "generated-images/product-1.png", "alt": "Front view" }
// → uploaded product-1.png · image/png · 245123 bytes · gid://shopify/MediaImage/123 · UPLOADED
// 直接给商品加图(本地文件会先自动上传)
{ "productId": "gid://shopify/Product/123", "path": "generated-images/product-1.png", "alt": "Front view" }
// 也可以传一个 Shopify 抓得到的图片 URL
{ "productId": "gid://shopify/Product/123", "url": "https://example.com/photo.jpg" }path 支持绝对路径,或相对会话工作区的路径——所以生图插件刚生成到工作区里的图片可以直接喂进来。传错了用 shopify_file_delete 删:商品媒体背后的文件是同一个 GID,删文件就同时把图从商品上摘掉(一次最多 50 个)。上传走 Shopify 官方三步:stagedUploadsCreate 拿签名目标 → multipart 把字节发到暂存服务 → fileCreate 落库;给商品加图走 productUpdate 的 media 参数。Shopify 处理图片是异步的,所以返回里 status 常常还是 PROCESSING,稍后自己会变 READY。
库存写入
set 和 adjust 是两个工具,不是同一个工具的两个参数——因为它们语义不同,混用会出事:set 会覆盖库位上的现有数字,adjust 才是在现有基础上加减。同步场景一般用 adjust,只有当你确定仓库真实数量时才用 set。
// 加减(推荐的同步方式)
{ "sku": "SKU-1234", "location": "主仓库", "delta": 20 }
// 设定绝对值(覆盖)
{ "sku": "SKU-1234", "location": "主仓库", "quantity": 120 }
// 带并发保护:只有当前值正好是 120 时才写,否则 Shopify 拒绝
{ "sku": "SKU-1234", "location": "主仓库", "quantity": 100, "expectedQuantity": 120 }location可以写库位名(中文也行,大小写不敏感)或 GID;写错会把这家店实际有的库位名列在报错里。sku和inventoryItemId二选一;两者都不给或都给都会被拒。- 数量名默认
available,可用name换成on_hand等;原因默认correction。 delta: 0、负数quantity、未知的数量名/原因都会在发请求前被拒。- 没写
expectedQuantity时会显式告诉 Shopify「本次覆盖」,写了就带上比对值——并发改动会被 Shopify 挡下来,而不是把别人的改动冲掉。
完整流水线:上传草稿 → 上架 → 库存
这三步在插件里已经闭环,全部在同一个「允许写入」开关下:
// 1) 新建草稿:选项 + 两个变体 + 价格 + SKU + 初始库存,一次调用
{
"title": "Silk Peony Stem",
"vendor": "Example Vendor",
"tags": ["peony", "silk"],
"options": [{ "name": "Size", "values": ["S", "M"] }],
"variants": [
{ "optionValues": [{ "optionName": "Size", "name": "S" }], "price": "27.99", "sku": "PEONY-S",
"quantities": [{ "locationId": "gid://shopify/Location/1", "quantity": 100 }] },
{ "optionValues": [{ "optionName": "Size", "name": "M" }], "price": "29.99", "sku": "PEONY-M" }
],
"images": [{ "url": "https://example.com/peony.jpg", "alt": "Peony stem" }]
}
// → created … · DRAFT · /products/… (默认草稿,不会自己跑到前台)
// 2) 上架:先把状态改成 ACTIVE,再发布到渠道(两步都要)
{ "id": "gid://shopify/Product/123", "status": "ACTIVE" } // shopify_product_update
{ "productId": "gid://shopify/Product/123" } // shopify_product_publish,默认 Online Store
{ "productId": "gid://shopify/Product/123", "channels": ["Shop"] } // 也可以指名渠道
// 3) 库存:已有库存记录的库位直接 set/adjust;新变体首次入库先 activate
{ "sku": "PEONY-M", "location": "主仓库", "quantity": 50 }
{ "sku": "PEONY-M", "location": "主仓库" } // shopify_inventory_activate几个刻意的设计:
- 新建商品默认 DRAFT,要 ACTIVE 必须显式写
status: "ACTIVE"——上传流程不会不小心把东西推到前台。 status: ACTIVE≠ 顾客能看到。Shopify 里这是两件事:状态 + 发布到渠道。所以上架是两步,shopify_product_publish补的是后一半。- 声明
options就必须给匹配的variants,否则 Shopify 只会生成一个价格为 0 的默认变体(这是productCreate的经典坑,productSet配上完整变体才不会有)。 shopify_product_delete是永久删除,Shopify 没有回收站;只是想下架请用status: DRAFT。- 创建时带库存用的是
productSet的inventoryQuantities,和后面inventory_set写的是同一份数据。
模型看到什么
每个工具返回精简后的结构化 JSON(GID、标题、金额、状态、时间),
render再生成紧凑文本给模型看,例如:orders: 2 - #1001 · gid://shopify/Order/1001 · 2026-09-10 · PAID / UNFULFILLED · 59.00 USD · Alice <[email protected]> · Dog Toy ×2 (DOG-S)金额一律
{ amount, currency }(Shopify 的 Money 本来就是这个形状);numberOfOrders这类 Int64 标量 Shopify 会序列化成字符串,插件已做兼容。UI 卡片走通用卡片(读
kind: read、写kind: edit、删除kind: delete);presentationMeta只带行数、是否还有下一页、改了几项这类计数,供将来做专用卡片。
已知限制
- 订单、客户、折扣、礼品卡没有写操作:插件只能读它们。发货、取消订单、退款都做不到。
shopify_product_delete不可撤销:Shopify 没有回收站。想下架用status: DRAFT,别用删除。- "上架"是两步:
status: ACTIVE只改状态,还要shopify_product_publish落到渠道,顾客才看得到。 all: true有上限:一次最多读 1000 行,超了返回nextCursor让你接着读;单页上限 250。- 受 Shopify 限流约束:Admin GraphQL 按 query cost 计费的漏桶;一次请求太大或太频繁会被拒(错误信息里会带
THROTTLED),把maxResults调小或稍后重试。 - 客户数据是 PII:
shopify_customers会返回邮箱等个人信息,注意你的部署里谁能看到会话记录。 - 只支持 myshopify 域名:Admin API 不能直接用自定义域名访问,
storeDomain必须是xxx.myshopify.com。 - 没有工具结果卡片:浏览器半边只注册「设置 → 插件」里的
dsh-shopify配置卡片,工具结果仍走通用卡片。
开发
npm install # 装依赖并执行 prepare 构建 lib/
npm test # 无密钥自测:Host 半边跑本地假 Shopify 服务;浏览器半边装进 __ModuleLoader__ 驱动设置卡片
npm run typechecknpm test 不起真实网络、不需要任何凭据:Host 侧把 globalThis.fetch 路由到本地假服务,客户端侧用无 DOM 的 React 替身驱动控制器。
