feat(cli)!: subject-based command tree (tea-style) + global --output flag

BREAKING:
- Flat oo verb-noun layout replaced with subject → verb, mirroring the
  gitea `tea` CLI. Every subject is one file in cmd/oo/:

    oo calendar       list | events | add | delete
    oo projects       list | get | milestones | create | update | delete
    oo tasks          list | get | create | update | delete | subtask add
    oo users          list | self                  (alias: oo whoami)
    oo contacts       list | get | delete | info-add
    oo persons        list | create | delete
    oo companies      list | create | delete
    oo opportunities  list | get | create | delete | stages | member-add
    oo cases          list | create | delete | member-add
    oo crm-tasks      list | create | delete | categories
    oo applications   sync

- Global `--output/-o table|json` flag on the root command; every list uses
  a single printTable helper that flattens nested bidCurrency → abbreviation
  for readability, truncates long cells, and dumps verbatim in JSON mode.

Library additions surfaced in the CLI:
- oo users / oo whoami via SelfUserID + GetUsers.
- oo projects full CRUD via Create/Update/Delete/GetProjectByID/Milestones.
- oo crm-tasks and oo cases via ListCRMTasks/CreateCRMTask/DeleteCRMTask/
  ListTaskCategories/ListCases/CreateCase/DeleteCase/AddCaseMember.
- oo contacts info-add via AddContactInfo.
- oo opportunities stages/member-add via ListDealStages/AddOpportunityMember.

Internal:
- cmd/oo/common.go — rootCmd, newOO (uses AuthenticateContext), printTable,
  printObject, fmtCell, deref* helpers.
- cmd/oo/crm.go deleted; content split into contacts.go, opportunities.go,
  cases.go, crm_tasks.go for locality.

Docs:
- README: new subject→verb table + migration note.
- AGENTS.md: CLI section rewritten; new rule enforcing subject→verb and
  printTable/printObject routing.
- CHANGELOG: 0.5.0 entry with full old→new mapping.

Made-with: Cursor
This commit is contained in:
2026-04-24 14:55:43 +01:00
parent 0973975471
commit 4eb14c082c
14 changed files with 1390 additions and 308 deletions
+7 -1
View File
@@ -11,7 +11,11 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go` — typed / untyped domain methods.
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. Split by domain: `main.go`, `common.go`, `calendar.go`, `tasks.go`, `crm.go`, `apps.go`. CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
- `main.go` — entry point (docstring lists the command tree).
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
- `calendar.go`, `projects.go`, `tasks.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject, each registers its subject `cobra.Command` in `init()` and attaches verb subcommands (`list`/`get`/`create`/`update`/`delete`/…).
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
## Rules
@@ -20,6 +24,8 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- New endpoints go into the library first; CLI commands are thin wrappers.
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
+58
View File
@@ -6,6 +6,64 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.5.0] - 2026-04-24
### Changed — CLI **BREAKING**
- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun`
layout is replaced with `oo <subject> <verb>`:
| Old | New |
|---|---|
| `oo cal-list` | `oo calendar list` |
| `oo cal-events` | `oo calendar events` |
| `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` |
| `oo task-list` | `oo tasks list` |
| `oo task-add` | `oo tasks create` |
| `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) |
| `oo subtask-add` | `oo tasks subtask add` |
| `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) |
| `oo crm-add-contact --company …` | `oo companies create --name …` |
| `oo crm-add-contact --person-first …` | `oo persons create --first …` |
| `oo crm-deals` | `oo opportunities list` |
| `oo crm-deals --stages` | `oo opportunities stages` |
| `oo crm-add-deal` | `oo opportunities create` |
| `oo crm-cases` | `oo cases list` |
| `oo applications-sync` | `oo applications sync` |
- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`,
`oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks
{list,create,delete,categories}`, `oo cases {create,delete,member-add}`,
`oo contacts {get,info-add}`.
- **Global `--output/-o` flag**: all list-like commands now support
`--output table` (default; tabwriter-aligned, truncated to 80 chars
per cell) and `--output json`. Nested `bidCurrency` flattened to its
`abbreviation` in the table view.
- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`,
`oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`,
`oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that
removes things.
### Added
- `cmd/oo/common.go` — shared `printTable(headers, rows)` and
`printObject(v)` helpers that dispatch on the `--output` flag.
- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami`
via the library's `GetUsers` + `SelfUserID`.
- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`,
`UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`.
- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from
project `oo tasks`).
- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared
list/filter implementation.
### Changed — library (minor)
- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())`
so CLI aborts propagate to the auth request.
## [0.4.0] - 2026-04-24
### Changed — project structure
+41 -9
View File
@@ -537,21 +537,53 @@ client.AddSubtask(ctx, "4242", "Prepare CV")
### oo (bundled command)
A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo`:
A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the
library lives under `cmd/oo`. The command tree is **subject-based**, mirroring
the [`tea`](https://gitea.com/gitea/tea) CLI:
```bash
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
oo cal-events
oo task-list --all --verbose
oo subtask-add 4242 "Prepare CV"
oo applications-sync --path ./applications/2026 --apply
# Global: every list-style command takes -o table|json (default: table)
oo whoami
oo users list
oo calendar events --start 2026-04-24 --end 2026-05-01
oo projects list
oo projects get 33
oo tasks list --all --verbose
oo tasks subtask add 4242 "Prepare CV"
oo persons create --first Jane --last Doe --email jane@example.com
oo companies create --name "Acme GmbH" --website https://acme.com
oo opportunities list
oo opportunities stages
oo cases list
oo crm-tasks categories
oo applications sync --path ./applications/2026 --apply
```
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library
itself never loads dotfiles). Run `oo --help` for the command reference.
| Subject | Verbs |
|---|---|
| `calendar` | `list`, `events`, `add`, `delete` |
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete` |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add` |
| `users` | `list`, `self` (alias: `oo whoami`) |
| `contacts` | `list`, `get`, `delete`, `info-add` |
| `persons` | `list` (filtered), `create`, `delete` |
| `companies` | `list` (filtered), `create`, `delete` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add` |
| `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` |
> **0.4.0 migration note:** the binary was previously named `oo-cli` and
> lived at `cmd/oo-cli`. The command set and flags are unchanged.
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library
itself never loads dotfiles). Run `oo --help` or `oo <subject> --help` for
the full command reference.
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`,
> `oo applications-sync`, …) were replaced by subject-based equivalents
> (`oo calendar events`, `oo tasks list`, `oo contacts list`,
> `oo applications sync`). Flags on leaf commands are unchanged.
## Environment Variables
+39 -24
View File
@@ -6,12 +6,26 @@ import (
"github.com/spf13/cobra"
)
func cmdCalList() *cobra.Command {
var calendarCmd = &cobra.Command{
Use: "calendar",
Aliases: []string{"cal"},
Short: "Calendars and events",
}
func init() {
rootCmd.AddCommand(calendarCmd)
calendarCmd.AddCommand(calListCmd())
calendarCmd.AddCommand(calEventsCmd())
calendarCmd.AddCommand(calAddCmd())
calendarCmd.AddCommand(calDeleteCmd())
}
func calListCmd() *cobra.Command {
return &cobra.Command{
Use: "cal-list",
Short: "List calendars (default date span)",
Use: "list",
Short: "List calendars",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -19,19 +33,19 @@ func cmdCalList() *cobra.Command {
if err != nil {
return err
}
printJSON(out)
printTable([]string{"objectId", "title", "textColor", "backgroundColor", "isEditable"}, out)
return nil
},
}
}
func cmdCalEvents() *cobra.Command {
func calEventsCmd() *cobra.Command {
var start, end string
cmd := &cobra.Command{
Use: "cal-events",
Short: "List calendar data for period (default: next 7 days)",
Use: "events",
Short: "List calendar events for a date range (default: next 7 days)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -43,7 +57,7 @@ func cmdCalEvents() *cobra.Command {
if err != nil {
return err
}
printJSON(out)
printTable([]string{"objectId", "title", "start", "end", "allDayLong"}, out)
return nil
},
}
@@ -52,15 +66,15 @@ func cmdCalEvents() *cobra.Command {
return cmd
}
func cmdCalAdd() *cobra.Command {
func calAddCmd() *cobra.Command {
var cal, desc string
var allDay bool
cmd := &cobra.Command{
Use: "cal-add TITLE START END",
Short: "Add calendar event",
Use: "add TITLE START END",
Short: "Add a calendar event",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -68,23 +82,24 @@ func cmdCalAdd() *cobra.Command {
if err != nil {
return err
}
printJSON(ev)
printObject(ev)
return nil
},
}
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env)")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().BoolVar(&allDay, "all-day", false, "")
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env OO_CALENDAR_ID)")
cmd.Flags().StringVar(&desc, "description", "", "event description")
cmd.Flags().BoolVar(&allDay, "all-day", false, "mark as all-day event")
return cmd
}
func cmdCalDel() *cobra.Command {
func calDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "cal-delete EVENT_ID [EVENT_ID...]",
Short: "Delete calendar events",
Args: cobra.MinimumNArgs(1),
Use: "delete EVENT_ID [EVENT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more calendar events",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -93,7 +108,7 @@ func cmdCalDel() *cobra.Command {
if err != nil {
return err
}
printJSON(out)
printObject(out)
}
return nil
},
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var casesCmd = &cobra.Command{
Use: "cases",
Aliases: []string{"case"},
Short: "CRM cases",
}
func init() {
rootCmd.AddCommand(casesCmd)
casesCmd.AddCommand(casesListCmd())
casesCmd.AddCommand(casesCreateCmd())
casesCmd.AddCommand(casesDeleteCmd())
casesCmd.AddCommand(casesMemberAddCmd())
}
func casesListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "isClosed", "created"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func casesCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a case",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCase(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func casesDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete CASE_ID [CASE_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more cases",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteCase(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func casesMemberAddCmd() *cobra.Command {
return &cobra.Command{
Use: "member-add CASE_ID CONTACT_ID",
Short: "Attach a contact to a case",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddCaseMember(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
+126 -22
View File
@@ -5,40 +5,36 @@ import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)
// outputFormat is the value of the global --output/-o flag. Valid values:
// "table" (default, tabwriter-rendered), "json" (machine-readable).
var outputFormat = "table"
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
Use: "oo",
Short: "OnlyOffice Workspace CLI — subject-based command tree",
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).",
SilenceUsage: true,
SilenceErrors: false,
}
func init() {
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "output format: table|json")
}
// execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() }
func init() {
rootCmd.AddCommand(cmdCalList())
rootCmd.AddCommand(cmdCalEvents())
rootCmd.AddCommand(cmdCalAdd())
rootCmd.AddCommand(cmdCalDel())
rootCmd.AddCommand(cmdTaskList())
rootCmd.AddCommand(cmdTaskAdd())
rootCmd.AddCommand(cmdSubtaskAdd())
rootCmd.AddCommand(cmdTaskUpdate())
rootCmd.AddCommand(cmdCRMContacts())
rootCmd.AddCommand(cmdCRMAddContact())
rootCmd.AddCommand(cmdCRMDeals())
rootCmd.AddCommand(cmdCRMAddDeal())
rootCmd.AddCommand(cmdCRMCases())
rootCmd.AddCommand(cmdAppsSync())
}
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO() (*onlyoffice.Client, error) {
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
_ = godotenv.Load()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
@@ -46,20 +42,110 @@ func newOO() (*onlyoffice.Client, error) {
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.Authenticate(); err != nil {
if err := c.AuthenticateContext(cmd.Context()); err != nil {
return nil, err
}
return c, nil
}
// printJSON dumps any value as indented JSON.
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// printObject renders a single value. JSON output dumps verbatim; table output
// prints key/value pairs when v is a map, otherwise falls back to JSON.
func printObject(v any) {
if outputFormat == "json" {
printJSON(v)
return
}
m, ok := v.(map[string]any)
if !ok {
printJSON(v)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
for _, k := range sortedKeys(m) {
fmt.Fprintf(w, "%s\t%s\n", k, fmtCell(m[k]))
}
_ = w.Flush()
}
// printTable renders a list of rows. headers select & order the columns.
// When outputFormat=="json" the raw slice is dumped as-is.
func printTable(headers []string, rows []map[string]any) {
if outputFormat == "json" {
printJSON(rows)
return
}
if len(rows) == 0 {
fmt.Println("(empty)")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
cells := make([]string, len(headers))
for i, h := range headers {
cells[i] = fmtCell(row[h])
}
fmt.Fprintln(w, strings.Join(cells, "\t"))
}
_ = w.Flush()
}
// fmtCell turns an arbitrary value into a compact table cell.
func fmtCell(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return truncate(x, 80)
case bool:
if x {
return "true"
}
return "false"
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case int, int32, int64:
return fmt.Sprintf("%d", x)
default:
b, _ := json.Marshal(x)
return truncate(string(b), 80)
}
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
func sortedKeys(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
// Deterministic ordering; small map so O(n log n) is fine.
for i := 1; i < len(ks); i++ {
for j := i; j > 0 && ks[j-1] > ks[j]; j-- {
ks[j-1], ks[j] = ks[j], ks[j-1]
}
}
return ks
}
// flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int
// / string into a float64. CLI-only; the library's own flexInt is unexported.
// / string into a float64.
func flexIDFloat(v any) float64 {
switch x := v.(type) {
case float64:
@@ -71,3 +157,21 @@ func flexIDFloat(v any) float64 {
return f
}
}
// idString returns the id field as a string (works for int/float/string).
func idString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
case int:
return strconv.Itoa(x)
default:
return fmt.Sprint(x)
}
}
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
// Contacts cover both persons and companies; OnlyOffice exposes them through
// the same `crm/contact/*` endpoint family. `oo persons` and `oo companies`
// are filtered views on top of `oo contacts list`, plus dedicated create
// commands that pick the right library call.
var contactsCmd = &cobra.Command{
Use: "contacts",
Aliases: []string{"contact"},
Short: "CRM contacts (persons + companies)",
}
var personsCmd = &cobra.Command{
Use: "persons",
Aliases: []string{"person"},
Short: "CRM persons (contacts with isCompany=false)",
}
var companiesCmd = &cobra.Command{
Use: "companies",
Aliases: []string{"company"},
Short: "CRM companies (contacts with isCompany=true)",
}
func init() {
rootCmd.AddCommand(contactsCmd)
rootCmd.AddCommand(personsCmd)
rootCmd.AddCommand(companiesCmd)
contactsCmd.AddCommand(contactsListCmd(nil))
contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsInfoAddCmd())
only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(contactsDeleteCmd())
onlyCo := false
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
companiesCmd.AddCommand(companiesCreateCmd())
companiesCmd.AddCommand(contactsDeleteCmd())
}
// contactsListCmd returns a `list` subcommand.
//
// - personsOnly == nil → list all contacts
// - personsOnly == &true → keep only persons (isCompany=false)
// - personsOnly == &false → keep only companies (isCompany=true)
func contactsListCmd(personsOnly *bool) *cobra.Command {
var search string
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListContacts(cmd.Context(), count, offset, search)
if err != nil {
return err
}
filtered := list
if personsOnly != nil {
filtered = filtered[:0]
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if *personsOnly && isCo {
continue
}
if !*personsOnly && !isCo {
continue
}
filtered = append(filtered, row)
}
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(filtered))
}
printTable([]string{"id", "displayName", "isCompany", "email", "companyName"}, filtered)
return nil
},
}
cmd.Flags().StringVar(&search, "search", "", "search filter")
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func contactsGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get CONTACT_ID",
Short: "Show a contact by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetContact(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func contactsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete CONTACT_ID [CONTACT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more contacts",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteContact(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func contactsInfoAddCmd() *cobra.Command {
var infoType, value, category string
var isPrimary bool
cmd := &cobra.Command{
Use: "info-add CONTACT_ID",
Short: "Add a contact info entry (email, phone, website, linkedin, …)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if infoType == "" || value == "" {
return fmt.Errorf("--type and --value are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddContactInfo(cmd.Context(), args[0], infoType, value, category, isPrimary)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&infoType, "type", "", "Email|Phone|Website|LinkedIn|…")
cmd.Flags().StringVar(&value, "value", "", "value")
cmd.Flags().StringVar(&category, "category", "Work", "Work|Home|Other")
cmd.Flags().BoolVar(&isPrimary, "primary", false, "mark as primary")
return cmd
}
func personsCreateCmd() *cobra.Command {
var first, last, email, linkedin string
var companyID int
var jobTitle, about string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a person",
RunE: func(cmd *cobra.Command, args []string) error {
if first == "" || last == "" {
return fmt.Errorf("--first and --last are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreatePerson(cmd.Context(), first, last, companyID, jobTitle, about)
if err != nil {
return err
}
pid := strconv.Itoa(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&first, "first", "", "first name")
cmd.Flags().StringVar(&last, "last", "", "last name")
cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id")
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
cmd.Flags().StringVar(&about, "about", "", "about / bio")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
return cmd
}
func companiesCreateCmd() *cobra.Command {
var name, email, website string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a company",
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" {
return fmt.Errorf("--name is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCompany(cmd.Context(), name)
if err != nil {
return err
}
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "company name")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
return cmd
}
-181
View File
@@ -1,181 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func cmdCRMContacts() *cobra.Command {
var companies, persons bool
var search string
cmd := &cobra.Command{
Use: "crm-contacts",
Short: "List CRM contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, _, err := c.ListContacts(cmd.Context(), 50, 0, search)
if err != nil {
return err
}
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if companies && !isCo {
continue
}
if persons && isCo {
continue
}
printJSON(row)
}
return nil
},
}
cmd.Flags().BoolVar(&companies, "companies", false, "")
cmd.Flags().BoolVar(&persons, "persons", false, "")
cmd.Flags().StringVar(&search, "search", "", "")
return cmd
}
func cmdCRMAddContact() *cobra.Command {
var company, email, website, linkedin string
var personFirst, personLast string
var companyID int
cmd := &cobra.Command{
Use: "crm-add-contact",
Short: "Add company or person",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if company != "" {
out, err := c.CreateCompany(cmd.Context(), company)
if err != nil {
return err
}
cid := fmt.Sprint(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
}
printJSON(out)
return nil
}
if personFirst != "" && personLast != "" {
out, err := c.CreatePerson(cmd.Context(), personFirst, personLast, companyID, "", "")
if err != nil {
return err
}
pid := fmt.Sprint(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
printJSON(out)
return nil
}
return fmt.Errorf("set --company or --person-first and --person-last")
},
}
cmd.Flags().StringVar(&company, "company", "", "")
cmd.Flags().StringVar(&personFirst, "person-first", "", "")
cmd.Flags().StringVar(&personLast, "person-last", "", "")
cmd.Flags().IntVar(&companyID, "company-id", 0, "")
cmd.Flags().StringVar(&email, "email", "", "")
cmd.Flags().StringVar(&website, "website", "", "")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "")
return cmd
}
func cmdCRMDeals() *cobra.Command {
var stages bool
cmd := &cobra.Command{
Use: "crm-deals",
Short: "List opportunities / stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stages {
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printJSON(out)
return nil
}
list, total, err := c.ListOpportunities(cmd.Context(), 100, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
cmd.Flags().BoolVar(&stages, "stages", false, "")
return cmd
}
func cmdCRMAddDeal() *cobra.Command {
var stage int
var bid float64
var contacts []string
cmd := &cobra.Command{
Use: "crm-add-deal TITLE",
Short: "Create opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, "", "EUR", "", bid)
if err != nil {
return err
}
did := fmt.Sprint(int(flexIDFloat(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printJSON(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "")
cmd.Flags().Float64Var(&bid, "bid", 0, "")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id (repeatable)")
return cmd
}
func cmdCRMCases() *cobra.Command {
return &cobra.Command{
Use: "crm-cases",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), 50, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var crmTasksCmd = &cobra.Command{
Use: "crm-tasks",
Aliases: []string{"crm-task"},
Short: "CRM tasks (standalone, not project tasks)",
}
func init() {
rootCmd.AddCommand(crmTasksCmd)
crmTasksCmd.AddCommand(crmTasksListCmd())
crmTasksCmd.AddCommand(crmTasksCreateCmd())
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
}
func crmTasksListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListCRMTasks(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func crmTasksCreateCmd() *cobra.Command {
var deadline, desc, entityType string
var categoryID, contactID, entityID int
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a CRM task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCRMTask(cmd.Context(), args[0], deadline, categoryID, contactID, entityType, entityID, desc)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
cmd.Flags().IntVar(&entityID, "entity-id", 0, "parent entity id")
cmd.Flags().StringVar(&desc, "description", "", "description")
return cmd
}
func crmTasksDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]",
Aliases: []string{"rm"},
Short: "Delete CRM tasks",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
if _, err := strconv.Atoi(id); err != nil {
return fmt.Errorf("task id %q must be integer: %w", id, err)
}
out, err := c.DeleteCRMTask(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func crmTasksCategoriesCmd() *cobra.Command {
return &cobra.Command{
Use: "categories",
Short: "List CRM task categories",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListTaskCategories(cmd.Context())
if err != nil {
return err
}
printTable([]string{"id", "title", "sortOrder", "imagePath"}, out)
return nil
},
}
}
+16 -6
View File
@@ -1,14 +1,24 @@
// Command oo is a thin CLI over the github.com/eslider/go-onlyoffice library.
// It exposes calendar, CRM, tasks, subtasks, and application-sync commands.
//
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
//
// oo calendar list | events | add | delete
// oo projects list | get | milestones | create | update | delete
// oo tasks list | get | create | update | delete | subtask add
// oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add
// oo persons list | create | delete
// oo companies list | create | delete
// oo opportunities list | get | create | delete | stages | member-add
// oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories
// oo applications sync
//
// Every list supports `--output/-o json|table` (table is the default).
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/oo@latest
//
// Rationale: previous versions shipped as `oo-cli` with the cobra tree living
// in `internal/cli`. Since the CLI is a consumer of the library — not a part
// of its public surface — cobra lives right here in package main, split by
// domain (calendar.go, crm.go, tasks.go, apps.go).
package main
import (
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var opportunitiesCmd = &cobra.Command{
Use: "opportunities",
Aliases: []string{"deals", "deal"},
Short: "CRM opportunities (a.k.a. deals)",
}
func init() {
rootCmd.AddCommand(opportunitiesCmd)
opportunitiesCmd.AddCommand(oppListCmd())
opportunitiesCmd.AddCommand(oppGetCmd())
opportunitiesCmd.AddCommand(oppCreateCmd())
opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd())
}
func oppListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List opportunities",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListOpportunities(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
// Flatten nested bidCurrency {abbreviation, ...} for readability.
for _, row := range list {
if cur, ok := row["bidCurrency"].(map[string]any); ok {
row["bidCurrency"] = cur["abbreviation"]
}
}
}
printTable([]string{"id", "title", "bidValue", "bidCurrency", "stageName"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 100, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func oppGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get OPPORTUNITY_ID",
Short: "Show an opportunity by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetOpportunity(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func oppCreateCmd() *cobra.Command {
var stage int
var bid float64
var currency, responsible, desc string
var contacts []string
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create an opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, responsible, currency, desc, bid)
if err != nil {
return err
}
did := strconv.Itoa(int(flexIDFloat(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printObject(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "pipeline stage id")
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
cmd.Flags().StringVar(&currency, "currency", "EUR", "bid currency")
cmd.Flags().StringVar(&responsible, "responsible", "", "responsible user id")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id to add as member (repeatable)")
return cmd
}
func oppDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more opportunities",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteOpportunity(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func oppStagesCmd() *cobra.Command {
return &cobra.Command{
Use: "stages",
Short: "List pipeline stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printTable([]string{"id", "title", "sortOrder", "color"}, out)
return nil
},
}
}
func oppMemberAddCmd() *cobra.Command {
return &cobra.Command{
Use: "member-add OPPORTUNITY_ID CONTACT_ID",
Short: "Attach a contact to an opportunity",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddOpportunityMember(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var projectsCmd = &cobra.Command{
Use: "projects",
Aliases: []string{"prj"},
Short: "OnlyOffice Projects",
}
func init() {
rootCmd.AddCommand(projectsCmd)
projectsCmd.AddCommand(prjListCmd())
projectsCmd.AddCommand(prjGetCmd())
projectsCmd.AddCommand(prjMilestonesCmd())
projectsCmd.AddCommand(prjCreateCmd())
projectsCmd.AddCommand(prjUpdateCmd())
projectsCmd.AddCommand(prjDeleteCmd())
}
func prjListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all projects",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.GetProjects()
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(list))
for _, p := range list {
row := map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
"status": derefInt(p.Status),
}
if p.TaskCount != nil {
row["tasks"] = *p.TaskCount
}
if p.IsPrivate != nil {
row["private"] = *p.IsPrivate
}
rows = append(rows, row)
}
printTable([]string{"id", "title", "status", "tasks", "private"}, rows)
return nil
},
}
}
func prjGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get [PROJECT_ID]",
Short: "Show a single project (default: $OO_PROJECT_ID)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id := ""
if len(args) == 1 {
id = args[0]
}
out, err := c.GetProjectByID(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func prjMilestonesCmd() *cobra.Command {
return &cobra.Command{
Use: "milestones PROJECT_ID",
Short: "List milestones of a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
ms, err := c.GetProjectMilestones(&onlyoffice.Project{ID: &pid})
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(ms))
for _, m := range ms {
rows = append(rows, map[string]any{
"id": derefInt64(m.ID),
"title": derefString(m.Title),
"status": derefInt64(m.Status),
})
}
printTable([]string{"id", "title", "status"}, rows)
return nil
},
}
}
func prjCreateCmd() *cobra.Command {
var desc, resp string
cmd := &cobra.Command{
Use: "create TITLE",
Short: "Create a new project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
Title: args[0],
Description: desc,
ResponsibleID: resp,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
})
return nil
},
}
cmd.Flags().StringVar(&desc, "description", "", "project description")
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
return cmd
}
func prjUpdateCmd() *cobra.Command {
var title, desc, resp string
cmd := &cobra.Command{
Use: "update PROJECT_ID",
Short: "Update project fields (only non-empty)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: desc,
ResponsibleID: resp,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
})
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "new title")
cmd.Flags().StringVar(&desc, "description", "", "new description")
cmd.Flags().StringVar(&resp, "responsible", "", "new responsible user id")
return cmd
}
func prjDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete PROJECT_ID [PROJECT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more projects",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, raw := range args {
id, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("project id %q must be integer: %w", raw, err)
}
p, err := c.DeleteProject(id)
if err != nil {
return err
}
printObject(map[string]any{"id": derefInt(p.ID), "title": p.String()})
}
return nil
},
}
}
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}
func derefInt64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
+120 -65
View File
@@ -6,14 +6,30 @@ import (
"github.com/spf13/cobra"
)
func cmdTaskList() *cobra.Command {
var tasksCmd = &cobra.Command{
Use: "tasks",
Aliases: []string{"task"},
Short: "Project tasks and subtasks",
}
func init() {
rootCmd.AddCommand(tasksCmd)
tasksCmd.AddCommand(taskListCmd())
tasksCmd.AddCommand(taskGetCmd())
tasksCmd.AddCommand(taskCreateCmd())
tasksCmd.AddCommand(taskUpdateCmd())
tasksCmd.AddCommand(taskDeleteCmd())
tasksCmd.AddCommand(subtaskCmd())
}
func taskListCmd() *cobra.Command {
var project, status string
var all, verbose bool
cmd := &cobra.Command{
Use: "task-list",
Use: "list",
Short: "List project tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -26,36 +42,54 @@ func cmdTaskList() *cobra.Command {
if err != nil {
return err
}
for _, t := range tasks {
fmt.Printf("[%v] %v status=%v\n", t["id"], t["title"], t["status"])
if verbose {
d, _ := c.GetTaskByID(cmd.Context(), fmt.Sprint(t["id"]))
subs, _ := d["subtasks"].([]any)
for _, s := range subs {
sm, _ := s.(map[string]any)
fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"])
if verbose {
for i, t := range tasks {
d, err := c.GetTaskByID(cmd.Context(), idString(t, "id"))
if err == nil {
tasks[i] = d
}
}
}
printTable([]string{"id", "title", "status", "priority", "responsible"}, tasks)
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id")
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "include subtasks & full details")
return cmd
}
func cmdTaskAdd() *cobra.Command {
var project, desc, deadline string
var prio string
cmd := &cobra.Command{
Use: "task-add TITLE",
Short: "Add project task",
func taskGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get TASK_ID",
Short: "Show a single task (incl. subtasks)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetTaskByID(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func taskCreateCmd() *cobra.Command {
var project, desc, deadline, prio string
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a project task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -70,24 +104,79 @@ func cmdTaskAdd() *cobra.Command {
if err != nil {
return err
}
printJSON(out)
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().StringVar(&deadline, "deadline", "", "")
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd
}
func cmdSubtaskAdd() *cobra.Command {
func taskUpdateCmd() *cobra.Command {
var status string
cmd := &cobra.Command{
Use: "update TASK_ID",
Short: "Update task status",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if status == "" {
return fmt.Errorf("--status is required (open|closed)")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.UpdateTaskStatus(cmd.Context(), args[0], status)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVarP(&status, "status", "s", "", "new status: open|closed")
return cmd
}
func taskDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add subtask(s)",
Use: "delete TASK_ID [TASK_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more tasks",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
// subtaskCmd exposes `oo tasks subtask {add}` — keeps things tidy without a
// dedicated subtasks top-level command.
func subtaskCmd() *cobra.Command {
sub := &cobra.Command{
Use: "subtask",
Short: "Subtasks of a parent task",
}
sub.AddCommand(&cobra.Command{
Use: "add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add one or more subtasks",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
c, err := newOO(cmd)
if err != nil {
return err
}
@@ -97,44 +186,10 @@ func cmdSubtaskAdd() *cobra.Command {
if err != nil {
return err
}
printJSON(out)
printObject(out)
}
return nil
},
}
}
func cmdTaskUpdate() *cobra.Command {
var del bool
cmd := &cobra.Command{
Use: "task-update TASK_ID open|closed",
Short: "Update task status or delete",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
id := args[0]
if del {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
return nil
}
if len(args) < 2 {
return fmt.Errorf("need status or --delete")
}
out, err := c.UpdateTaskStatus(cmd.Context(), id, args[1])
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().BoolVar(&del, "delete", false, "delete task")
return cmd
})
return sub
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"github.com/spf13/cobra"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Portal / workspace users",
}
func init() {
rootCmd.AddCommand(usersCmd)
usersCmd.AddCommand(usersListCmd())
usersCmd.AddCommand(usersSelfCmd())
rootCmd.AddCommand(whoamiCmd())
}
func usersListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List portal users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
users, err := c.GetUsers()
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(users))
for _, u := range users {
rows = append(rows, map[string]any{
"id": derefString(u.ID),
"userName": derefString(u.UserName),
"displayName": derefString(u.DisplayName),
"email": derefString(u.Email),
"isAdmin": derefBool(u.IsAdmin),
"status": derefInt(u.Status),
})
}
printTable([]string{"id", "userName", "displayName", "email", "isAdmin", "status"}, rows)
return nil
},
}
}
func usersSelfCmd() *cobra.Command {
return &cobra.Command{
Use: "self",
Short: "Show current user id (people/@self)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
printObject(map[string]any{"id": id})
return nil
},
}
}
// whoamiCmd is a convenience shortcut at the root level.
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Alias for `oo users self`",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
printObject(map[string]any{"id": id})
return nil
},
}
}
func derefBool(p *bool) bool {
if p == nil {
return false
}
return *p
}