feat: add wiki shortcut group

This commit is contained in:
Mengz 2026-05-26 17:28:05 +08:00
parent 52b7093846
commit 8bd3ceb7c8
10 changed files with 710 additions and 21 deletions

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, wiki pages, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
@ -83,7 +83,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
## Why gitlink-cli?
- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **Wide Coverage** — Repository, Wiki, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
@ -96,6 +96,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
| 📚 Wiki | List, view, create, update, and delete wiki pages |
| 🐛 Issue | Create, update, close, batch close, comment on issues |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
@ -246,6 +247,27 @@ gitlink-cli repo +create -n my-project -d "Project description"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Wiki Management
```bash
# List wiki pages
gitlink-cli wiki +list --owner Gitlink --repo forgeplus
# View a wiki page
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --page Home
# Create a wiki page from inline content
gitlink-cli wiki +create --owner Gitlink --repo forgeplus \
--page Home --title Home --content "Welcome to the project wiki"
# Update a wiki page from a Markdown file
gitlink-cli wiki +update --owner Gitlink --repo forgeplus \
--page Home --file docs/wiki-home.md --message "Update Home"
# Delete a wiki page
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --page Home
```
### Webhook Management
```bash
@ -638,6 +660,7 @@ See [skills/README.md](skills/README.md) for details.
|-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
| `gitlink-wiki` | Wiki operations (list, view, create, update, delete) |
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Wiki、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
**[English](./README.md)**
@ -83,7 +83,7 @@
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **广泛覆盖** — 仓库、Wiki、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
@ -96,6 +96,7 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| 📚 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
@ -257,6 +258,27 @@ gitlink-cli repo +create -n my-project -d "项目描述"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Wiki 管理
```bash
# 列出 Wiki 页面
gitlink-cli wiki +list --owner Gitlink --repo forgeplus
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --page Home
# 使用命令行内容创建 Wiki 页面
gitlink-cli wiki +create --owner Gitlink --repo forgeplus \
--page Home --title Home --content "欢迎来到项目 Wiki"
# 使用 Markdown 文件更新 Wiki 页面
gitlink-cli wiki +update --owner Gitlink --repo forgeplus \
--page Home --file docs/wiki-home.md --message "更新 Home"
# 删除 Wiki 页面
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --page Home
```
### Webhook 管理
```bash
@ -517,6 +539,7 @@ git push gitlink
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork、洞察数据等 |
| `gitlink-wiki` | Wiki 操作(列出、查看、创建、更新、删除) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |

View File

@ -0,0 +1,13 @@
# Wiki Shortcut
Added a new `wiki` shortcut group for repository wiki management.
Included commands:
- `wiki +list`
- `wiki +view`
- `wiki +create`
- `wiki +update`
- `wiki +delete`
The shortcut auto-resolves GitLink `projectId` from repository metadata when `--project-id` is omitted, supports both inline content and file-backed Markdown content, base64-encodes wiki content for the API, and validates conflicting or incomplete input before sending requests.

View File

@ -83,6 +83,12 @@
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
"cmd.webhook.view.short": "View webhook details",
"cmd.wiki.create.short": "Create a wiki page",
"cmd.wiki.delete.short": "Delete a wiki page",
"cmd.wiki.list.short": "List wiki pages",
"cmd.wiki.short": "Wiki operations",
"cmd.wiki.update.short": "Update a wiki page",
"cmd.wiki.view.short": "View a wiki page",
"error.auth.delete_token_failed": "failed to delete token: {message}",
"error.auth.login_failed": "login failed: {message}",
"error.auth.store_token_failed": "failed to store token: {message}",
@ -90,13 +96,13 @@
"error.config.save_failed": "failed to save config: {message}",
"error.missing_required_flag": "required flag --{name} is missing",
"error.unsupported_language": "unsupported language: {lang}",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
"flag.api.batch_file": "Read an API batch plan from a JSON file",
"flag.api.batch_var": "Override a batch template variable (key=value, repeatable)",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.header": "Additional headers (key:value)",
"flag.api.query": "Query parameters (key=val&key2=val2)",
"flag.auth.token": "Login by pasting an existing token",
@ -113,6 +119,8 @@
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.batch.reason": "Optional reason shown in the batch result",
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
@ -120,8 +128,6 @@
"flag.issue.batch_label.state": "Filter by issue state",
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.body": "Issue description",
"flag.issue.label": "Label ID",
"flag.issue.label_filter": "Filter by existing label",
@ -149,14 +155,14 @@
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.priority_id": "Priority ID",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.pr.review_content": "Review content",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.tag_id": "Issue tag ID",
"flag.pr.title": "PR title",
@ -190,11 +196,16 @@
"flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.",
"flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
"flag.webhook.url": "Webhook target URL",
"flag.wiki.content": "Wiki page content",
"flag.wiki.file": "Read wiki page content from file",
"flag.wiki.message": "Commit message",
"flag.wiki.page": "Wiki page name",
"flag.wiki.project_id": "GitLink project ID (auto-resolved when omitted)",
"flag.wiki.title": "Wiki page title (defaults to --page)",
"output.auth.env_hint": " Or set {env} environment variable",
"output.auth.login_hint": " Run: gitlink-cli auth login",
"output.config.file": "Config file: {path}",
"output.config.not_set": "(not set)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",
"output.doctor.api_auth.failed": "Authenticated API request failed: {message}",
"output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.",
@ -217,6 +228,7 @@
"output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.",
"output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.",
"output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "Password: ",
"prompt.auth.token": "Paste your access token: ",
"prompt.auth.username": "Username/Email/Phone: ",

View File

@ -83,6 +83,12 @@
"cmd.webhook.test.short": "触发 Webhook 测试投递",
"cmd.webhook.update.short": "更新仓库 Webhook并在可用时保留未指定字段",
"cmd.webhook.view.short": "查看 Webhook 详情",
"cmd.wiki.create.short": "创建 Wiki 页面",
"cmd.wiki.delete.short": "删除 Wiki 页面",
"cmd.wiki.list.short": "列出 Wiki 页面",
"cmd.wiki.short": "Wiki 操作",
"cmd.wiki.update.short": "更新 Wiki 页面",
"cmd.wiki.view.short": "查看 Wiki 页面",
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
"error.auth.login_failed": "登录失败:{message}",
"error.auth.store_token_failed": "保存 Token 失败:{message}",
@ -90,13 +96,13 @@
"error.config.save_failed": "保存配置失败:{message}",
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
"flag.api.batch_var": "覆盖批处理模板变量key=value可重复",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.header": "附加请求头key:value",
"flag.api.query": "查询参数key=val&key2=val2",
"flag.auth.token": "通过粘贴已有 Token 登录",
@ -113,6 +119,8 @@
"flag.format": "输出格式json、table、yaml默认table",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.batch.reason": "批量结果中显示的可选原因",
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
@ -120,8 +128,6 @@
"flag.issue.batch_label.state": "按议题状态筛选",
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.body": "议题描述",
"flag.issue.label": "标签 ID",
"flag.issue.label_filter": "按已有标签筛选",
@ -149,14 +155,14 @@
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.priority_id": "优先级 ID",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.pr.review_content": "评审内容",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.tag_id": "议题标签 ID",
"flag.pr.title": "PR 标题",
@ -190,11 +196,16 @@
"flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。",
"flag.webhook.type": "Webhook 类型gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
"flag.webhook.url": "Webhook 目标 URL",
"flag.wiki.content": "Wiki 页面内容",
"flag.wiki.file": "从文件读取 Wiki 页面内容",
"flag.wiki.message": "提交说明",
"flag.wiki.page": "Wiki 页面名称",
"flag.wiki.project_id": "GitLink 项目 ID未传入时自动解析",
"flag.wiki.title": "Wiki 页面标题(默认使用 --page",
"output.auth.env_hint": " 或设置 {env} 环境变量",
"output.auth.login_hint": " 运行gitlink-cli auth login",
"output.config.file": "配置文件:{path}",
"output.config.not_set": "(未设置)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",
"output.doctor.api_auth.failed": "认证 API 请求失败:{message}",
"output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。",
@ -217,6 +228,7 @@
"output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。",
"output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。",
"output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "密码:",
"prompt.auth.token": "粘贴你的访问 Token",
"prompt.auth.username": "用户名/邮箱/手机号:",

View File

@ -22,6 +22,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"webhook": webhook.Shortcuts(tr),
"health": health.Shortcuts(tr),
"workflow": workflow.Shortcuts(),
"wiki": wiki.Shortcuts(tr),
}
descriptions := map[string]string{
@ -71,6 +73,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"webhook": tr.T("cmd.webhook.short"),
"health": "Project health data collection",
"workflow": "AI agent workflow analysis",
"wiki": tr.T("cmd.wiki.short"),
}
for name, shortcuts := range groups {

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"health",
"health", "wiki",
}
groupSet := map[string]bool{}

297
shortcuts/wiki/wiki.go Normal file
View File

@ -0,0 +1,297 @@
package wiki
import (
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const (
wikiListPath = "/api/wiki/wikiPages"
wikiViewPath = "/api/wiki/getWiki"
wikiCreatePath = "/api/wiki/createWiki"
wikiUpdatePath = "/api/wiki/updateWiki"
wikiDeletePath = "/api/wiki/deleteWiki"
)
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
{
Name: "list",
Description: tr.T("cmd.wiki.list.short"),
Flags: []common.Flag{
{Name: "project-id", Usage: tr.T("flag.wiki.project_id")},
},
Run: runListWikiPages,
},
{
Name: "view",
Description: tr.T("cmd.wiki.view.short"),
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true},
{Name: "project-id", Usage: tr.T("flag.wiki.project_id")},
},
Run: runViewWikiPage,
},
{
Name: "create",
Description: tr.T("cmd.wiki.create.short"),
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true},
{Name: "title", Short: "t", Usage: tr.T("flag.wiki.title")},
{Name: "content", Short: "c", Usage: tr.T("flag.wiki.content")},
{Name: "file", Short: "f", Usage: tr.T("flag.wiki.file")},
{Name: "message", Short: "m", Usage: tr.T("flag.wiki.message")},
{Name: "project-id", Usage: tr.T("flag.wiki.project_id")},
},
Run: runCreateWikiPage,
},
{
Name: "update",
Description: tr.T("cmd.wiki.update.short"),
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true},
{Name: "title", Short: "t", Usage: tr.T("flag.wiki.title")},
{Name: "content", Short: "c", Usage: tr.T("flag.wiki.content")},
{Name: "file", Short: "f", Usage: tr.T("flag.wiki.file")},
{Name: "message", Short: "m", Usage: tr.T("flag.wiki.message")},
{Name: "project-id", Usage: tr.T("flag.wiki.project_id")},
},
Run: runUpdateWikiPage,
},
{
Name: "delete",
Description: tr.T("cmd.wiki.delete.short"),
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true},
{Name: "project-id", Usage: tr.T("flag.wiki.project_id")},
},
Run: runDeleteWikiPage,
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func runListWikiPages(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
query, err := buildWikiQuery(ctx, "")
if err != nil {
return err
}
env, err := callWikiAPIWithQuery(ctx, "GET", wikiListPath, query)
if err != nil {
return err
}
return ctx.Output(env)
}
func runViewWikiPage(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
page, err := ctx.RequireArg("page")
if err != nil {
return err
}
query, err := buildWikiQuery(ctx, page)
if err != nil {
return err
}
env, err := callWikiAPIWithQuery(ctx, "GET", wikiViewPath, query)
if err != nil {
return err
}
return ctx.Output(env)
}
func runCreateWikiPage(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload, err := buildWikiWritePayload(ctx, true)
if err != nil {
return err
}
env, err := callWikiAPI(ctx, "POST", wikiCreatePath, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runUpdateWikiPage(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload, err := buildWikiWritePayload(ctx, false)
if err != nil {
return err
}
env, err := callWikiAPI(ctx, "PUT", wikiUpdatePath, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runDeleteWikiPage(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
page, err := ctx.RequireArg("page")
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": page,
}
env, err := callWikiAPI(ctx, "DELETE", wikiDeletePath, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func buildWikiQuery(ctx *common.RuntimeContext, page string) (url.Values, error) {
projectID, err := resolveProjectID(ctx)
if err != nil {
return nil, err
}
query := url.Values{}
query.Set("owner", ctx.Owner)
query.Set("repo", ctx.Repo)
query.Set("projectId", strconv.Itoa(projectID))
if page != "" {
query.Set("pageName", page)
}
return query, nil
}
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
env, err := ctx.CallAPI(method, path, body)
if err != nil {
return nil, wrapWikiAPIError(path, err)
}
return env, nil
}
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
env, err := ctx.CallAPIWithQuery(method, path, query)
if err != nil {
return nil, wrapWikiAPIError(path, err)
}
return env, nil
}
func wrapWikiAPIError(path string, err error) error {
var apiErr *client.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
return fmt.Errorf("GitLink Wiki API %s returned 404; confirm the repository has Wiki enabled and the Wiki OpenAPI is available: %w", path, err)
}
return err
}
func buildWikiWritePayload(ctx *common.RuntimeContext, requireContent bool) (map[string]interface{}, error) {
page, err := ctx.RequireArg("page")
if err != nil {
return nil, err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return nil, err
}
title := ctx.Arg("title")
if title == "" {
title = page
}
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": page,
"title": title,
}
if message := ctx.Arg("message"); message != "" {
payload["message"] = message
}
contentBase64, ok, err := readWikiContent(ctx)
if err != nil {
return nil, err
}
if ok {
payload["content_base64"] = contentBase64
} else if requireContent {
return nil, fmt.Errorf("one of --content or --file is required")
}
return payload, nil
}
func readWikiContent(ctx *common.RuntimeContext) (string, bool, error) {
content := ctx.Arg("content")
filePath := ctx.Arg("file")
if content != "" && filePath != "" {
return "", false, fmt.Errorf("use only one of --content or --file")
}
if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", false, fmt.Errorf("read wiki file: %w", err)
}
return base64.StdEncoding.EncodeToString(data), true, nil
}
if content != "" {
return base64.StdEncoding.EncodeToString([]byte(content)), true, nil
}
return "", false, nil
}
func resolveProjectID(ctx *common.RuntimeContext) (int, error) {
if value := strings.TrimSpace(ctx.Arg("project-id")); value != "" {
projectID, err := strconv.Atoi(value)
if err != nil || projectID <= 0 {
return 0, fmt.Errorf("invalid --project-id %q", value)
}
return projectID, nil
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("resolve project ID: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected repository response format")
}
for _, key := range []string{"project_id", "id"} {
if value, ok := data[key].(float64); ok && value > 0 {
return int(value), nil
}
}
return 0, fmt.Errorf("repository response missing project_id")
}

263
shortcuts/wiki/wiki_test.go Normal file
View File

@ -0,0 +1,263 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestWikiListAutoResolvesProjectID(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"project_id": float64(1546652)})
case r.Method == "GET" && r.URL.Path == "/api/wiki/wikiPages.json":
called = true
query := r.URL.Query()
assertEqual(t, query.Get("owner"), "owner")
assertEqual(t, query.Get("repo"), "repo")
assertEqual(t, query.Get("projectId"), "1546652")
writeJSON(t, w, map[string]interface{}{"message": "success", "data": []interface{}{}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
if !called {
t.Fatal("wiki list endpoint was not called")
}
}
func TestWikiViewUsesExplicitProjectID(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/api/wiki/getWiki.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
called = true
query := r.URL.Query()
assertEqual(t, query.Get("owner"), "owner")
assertEqual(t, query.Get("repo"), "repo")
assertEqual(t, query.Get("projectId"), "42")
assertEqual(t, query.Get("pageName"), "Home")
writeJSON(t, w, map[string]interface{}{"message": "success", "data": map[string]interface{}{"title": "Home"}})
}))
defer server.Close()
err := runWikiShortcut(t, server, "view", map[string]string{
"page": "Home",
"project-id": "42",
})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
if !called {
t.Fatal("wiki view endpoint was not called")
}
}
func TestWikiCreateBuildsPayloadFromContent(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"project_id": float64(1546652)})
case r.Method == "POST" && r.URL.Path == "/api/wiki/createWiki.json":
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "201"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{
"page": "Home",
"title": "Welcome",
"content": "hello wiki",
"message": "Add Home",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["owner"], "owner")
assertEqual(t, payload["repo"], "repo")
assertEqual(t, payload["projectId"], float64(1546652))
assertEqual(t, payload["pageName"], "Home")
assertEqual(t, payload["title"], "Welcome")
assertEqual(t, payload["message"], "Add Home")
assertEqual(t, payload["content_base64"], base64.StdEncoding.EncodeToString([]byte("hello wiki")))
}
func TestWikiUpdateBuildsPayloadFromFile(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "wiki.md")
if err := os.WriteFile(filePath, []byte("# Updated\n"), 0o644); err != nil {
t.Fatalf("write wiki file: %v", err)
}
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/api/wiki/updateWiki.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "success"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{
"page": "Home",
"file": filePath,
"project-id": "42",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["projectId"], float64(42))
assertEqual(t, payload["pageName"], "Home")
assertEqual(t, payload["title"], "Home")
assertEqual(t, payload["content_base64"], base64.StdEncoding.EncodeToString([]byte("# Updated\n")))
}
func TestWikiDeleteSendsBody(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/api/wiki/deleteWiki.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "success"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "delete", map[string]string{
"page": "Home",
"project-id": "42",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
assertEqual(t, payload["owner"], "owner")
assertEqual(t, payload["repo"], "repo")
assertEqual(t, payload["projectId"], float64(42))
assertEqual(t, payload["pageName"], "Home")
}
func TestWikiOpenAPI404HasActionableError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/api/wiki/wikiPages.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"status": 404, "message": "not found"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "list", map[string]string{
"project-id": "42",
})
if err == nil {
t.Fatal("expected wiki API 404 to fail")
}
if !strings.Contains(err.Error(), "GitLink Wiki API /api/wiki/wikiPages returned 404") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), "Wiki OpenAPI is available") {
t.Fatalf("expected actionable wiki API hint, got: %v", err)
}
}
func TestWikiRejectsInvalidInput(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
if err := runWikiShortcut(t, server, "view", map[string]string{
"page": "Home",
"project-id": "abc",
}); err == nil {
t.Fatal("expected invalid project-id to fail")
}
if err := runWikiShortcut(t, server, "create", map[string]string{
"page": "Home",
"project-id": "42",
}); err == nil {
t.Fatal("expected missing content to fail")
}
if err := runWikiShortcut(t, server, "create", map[string]string{
"page": "Home",
"project-id": "42",
"content": "hello",
"file": "wiki.md",
}); err == nil {
t.Fatal("expected content/file conflict to fail")
}
}
func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWikiShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findWikiShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -0,0 +1,43 @@
---
name: gitlink-wiki
version: 1.0.0
description: "Wiki management: list, view, create, update, and delete repository wiki pages."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli wiki --help"
shortcuts:
- "wiki +list"
- "wiki +view"
- "wiki +create"
- "wiki +update"
- "wiki +delete"
---
# gitlink-wiki
## Shortcuts
| Shortcut | Description |
|----------|-------------|
| `wiki +list` | List repository wiki pages |
| `wiki +view` | View a wiki page |
| `wiki +create` | Create a wiki page |
| `wiki +update` | Update a wiki page |
| `wiki +delete` | Delete a wiki page |
## Examples
```bash
gitlink-cli wiki +list --owner Gitlink --repo forgeplus
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --page Home
gitlink-cli wiki +create --owner Gitlink --repo forgeplus \
--page Home --title Home --content "Welcome to the project wiki"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus \
--page Home --file docs/wiki-home.md --message "Update Home"
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --page Home
```
## Notes
`--project-id` is optional. When omitted, the CLI reads repository metadata and uses the returned `project_id`.