Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions cli/cmd_search.go

This file was deleted.

149 changes: 19 additions & 130 deletions cli/root.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
// Package cli builds the ctrip command tree on top of the ctrip library.
// Package cli assembles the ctrip command tree from the ctrip domain
// on top of the any-cli/kit framework.
package cli

import (
"fmt"
"io"
"os"

"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/tamnd/any-cli/kit"
"github.com/tamnd/ctrip-cli/ctrip"
"github.com/tamnd/ctrip-cli/pkg/render"
)

// Build metadata, set via -ldflags at release time.
Expand All @@ -19,126 +14,20 @@ var (
Date = "unknown"
)

// exit codes.
const (
exitError = 1
exitUsage = 2
exitNoData = 3
)

// ExitError carries a process exit code up to main.
type ExitError struct {
Code int
Err error
}

func (e *ExitError) Error() string {
if e.Err != nil {
return e.Err.Error()
}
return fmt.Sprintf("exit %d", e.Code)
}

func (e *ExitError) Unwrap() error { return e.Err }

func codeError(code int, err error) error { return &ExitError{Code: code, Err: err} }

// App holds shared state threaded through every command.
type App struct {
client *ctrip.Client
cfg ctrip.Config

output string
fields []string
noHeader bool
template string
limit int
quiet bool
}

// Root builds the root command and its subtree.
func Root() *cobra.Command {
app := &App{cfg: ctrip.DefaultConfig()}

root := &cobra.Command{
Use: "ctrip",
Short: "Search Ctrip hotels and destinations (携程)",
Long: `ctrip searches Ctrip (携程) hotels and destinations through the
public mobile JSON API. No API key is required. It returns records as
table, JSON, JSONL, CSV, or TSV.

ctrip is an independent tool and is not affiliated with Ctrip or Trip.com.`,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return app.setup()
},
}

pf := root.PersistentFlags()
pf.StringVarP(&app.output, "output", "o", "auto", "output: table|json|jsonl|csv|tsv (auto=table on TTY, jsonl piped)")
pf.StringSliceVar(&app.fields, "fields", nil, "comma-separated columns to include")
pf.BoolVar(&app.noHeader, "no-header", false, "omit the header row in table/csv/tsv")
pf.StringVar(&app.template, "template", "", "Go text/template applied per record")
pf.IntVarP(&app.limit, "limit", "n", 0, "limit number of records (0 = command default)")
pf.BoolVarP(&app.quiet, "quiet", "q", false, "suppress progress on stderr")

pf.DurationVar(&app.cfg.Rate, "delay", app.cfg.Rate, "minimum spacing between requests")
pf.DurationVar(&app.cfg.Timeout, "timeout", app.cfg.Timeout, "per-request timeout")
pf.IntVar(&app.cfg.Retries, "retries", app.cfg.Retries, "retry attempts on 429/5xx")
pf.StringVar(&app.cfg.UserAgent, "user-agent", app.cfg.UserAgent, "User-Agent sent with each request")

root.AddCommand(
app.searchCmd(),
newVersionCmd(),
)
return root
}

func (a *App) setup() error {
if a.output == "" || a.output == "auto" {
if isatty.IsTerminal(os.Stdout.Fd()) {
a.output = string(render.FormatTable)
} else {
a.output = string(render.FormatJSONL)
}
}
if !render.Format(a.output).Valid() {
return codeError(exitUsage, fmt.Errorf("unknown output format %q", a.output))
}
a.client = ctrip.NewClient(a.cfg)
return nil
}

func (a *App) renderTo(w io.Writer, records any) error {
r := render.New(w, render.Format(a.output), a.fields, a.noHeader, a.template)
return r.Render(records)
}

func (a *App) render(records any) error {
return a.renderTo(os.Stdout, records)
}

func (a *App) renderOrEmpty(records any, n int) error {
if err := a.render(records); err != nil {
return err
}
if n == 0 {
return codeError(exitNoData, nil)
}
return nil
}

func (a *App) progressf(format string, args ...any) {
if a.quiet {
return
}
_, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
}

func (a *App) effectiveLimit(def int) int {
if a.limit > 0 {
return a.limit
}
return def
// NewApp assembles the kit application from the ctrip domain. The
// domain's Register installs the client factory and every operation, so the
// binary and a host that blank-imports the package share one source of truth.
// kit.Run turns the App into the CLI, plus the serve and mcp surfaces and
// the typed-error-to-exit-code mapping.
//
// To add a command, declare it in ctrip/domain.go with kit.Handle and it
// appears here automatically.
func NewApp() *kit.App {
id := ctrip.Domain{}.Info().Identity
id.Version = Version

app := kit.New(id)
(ctrip.Domain{}).Register(app)
app.AddCommand(newVersionCmd())
return app
}
43 changes: 28 additions & 15 deletions cli/version.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
package cli

import (
"context"
"fmt"
"os"
"runtime"

"github.com/spf13/cobra"
"github.com/tamnd/any-cli/kit"
)

func newVersionCmd() *cobra.Command {
var short bool
cmd := &cobra.Command{
// versionCmd prints build info rather than emitting records, so it does not
// fit the operation shape. fang also wires --version from the App identity;
// this adds a `version` subcommand and a --short form for scripts.
type versionCmd struct{ short bool }

func newVersionCmd() kit.Command {
v := &versionCmd{}
return kit.Command{
Use: "version",
Short: "Print version information",
RunE: func(c *cobra.Command, _ []string) error {
if short {
_, _ = fmt.Fprintln(c.OutOrStdout(), Version)
return nil
}
_, _ = fmt.Fprintf(c.OutOrStdout(), "ctrip %s (commit %s, built %s, %s/%s, %s)\n",
Version, Commit, Date, runtime.GOOS, runtime.GOARCH, runtime.Version())
return nil
},
Args: kit.NoArgs,
Flags: v.flags,
Run: v.run,
}
}

func (v *versionCmd) flags(f *kit.FlagSet) {
f.BoolVar(&v.short, "short", false, "print just the version number")
}

func (v *versionCmd) run(_ context.Context, _ []string) error {
if v.short {
_, _ = fmt.Fprintln(os.Stdout, Version)
return nil
}
cmd.Flags().BoolVar(&short, "short", false, "print just the version number")
return cmd
_, _ = fmt.Fprintf(os.Stdout, "ctrip %s (commit %s, built %s, %s/%s, %s)\n",
Version, Commit, Date, runtime.GOOS, runtime.GOARCH, runtime.Version())
return nil
}
15 changes: 5 additions & 10 deletions cmd/ctrip/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,16 @@ import (
"os/signal"
"syscall"

"github.com/charmbracelet/fang"
"github.com/tamnd/any-cli/kit"
"github.com/tamnd/ctrip-cli/cli"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

root := cli.Root()
// fang gives styled help, errors, and shell completion for free; the command
// tree and its exit-code mapping stay in the cli package.
if err := fang.Execute(ctx, root,
fang.WithVersion(cli.Version),
fang.WithNotifySignal(os.Interrupt, syscall.SIGTERM),
); err != nil {
os.Exit(1)
}
// kit builds the command tree from the registry, adds the serve and mcp
// surfaces, wraps it in fang for help and completion, and maps the typed
// error taxonomy to exit codes. The release ldflags set cli.Version.
os.Exit(kit.Run(ctx, cli.NewApp()))
}
104 changes: 104 additions & 0 deletions ctrip/domain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package ctrip

import (
"context"

"github.com/tamnd/any-cli/kit"
)

// domain.go exposes Ctrip as a kit Domain so a multi-domain host can
// blank-import it:
//
// import _ "github.com/tamnd/ctrip-cli/ctrip"
//
// The same Domain also builds the standalone ctrip binary (cmd/ctrip).

const (
// Host is the primary Ctrip domain.
Host = "www.ctrip.com"
)

func init() { kit.Register(Domain{}) }

// Domain is the Ctrip driver.
type Domain struct{}

func (Domain) Info() kit.DomainInfo {
return kit.DomainInfo{
Scheme: "ctrip",
Hosts: []string{Host},
Identity: kit.Identity{
Binary: "ctrip",
Short: "Search Ctrip (携程) hotels and destinations.",
Long: `Search Ctrip (携程) hotels and destinations.

ctrip searches Ctrip via the public mobile JSON API at m.ctrip.com.
No API key is required. It returns clean JSON records that pipe into
jq, sqlite-utils, and your own scripts.`,
Site: Host,
Repo: "https://github.com/tamnd/ctrip-cli",
},
}
}

func (Domain) Register(app *kit.App) {
app.SetClient(newKitClient)

// search: keyword search for hotels and destinations.
kit.Handle(app, kit.OpMeta{
Name: "search",
Group: "read",
List: true,
URIType: "result",
Summary: "Search Ctrip hotels and destinations by keyword",
Args: []kit.Arg{{Name: "keyword", Help: "search keyword (e.g. Paris, 上海)"}},
}, searchHandler)
}

// newKitClient builds the Ctrip client from the kit host config.
func newKitClient(_ context.Context, cfg kit.Config) (any, error) {
c := DefaultConfig()
if cfg.UserAgent != "" {
c.UserAgent = cfg.UserAgent
}
if cfg.Rate > 0 {
c.Rate = cfg.Rate
}
if cfg.Retries > 0 {
c.Retries = cfg.Retries
}
if cfg.Timeout > 0 {
c.Timeout = cfg.Timeout
}
return NewClient(c), nil
}

// --- inputs ---

type searchInput struct {
Keyword string `kit:"arg" help:"search keyword (e.g. Paris, 上海)"`
Limit int `kit:"flag,inherit" help:"max results"`
Client *Client `kit:"inject"`
}

// --- handlers ---

func searchHandler(ctx context.Context, in searchInput, emit func(*Result) error) error {
limit := in.Limit
if limit <= 0 {
limit = 15
}
results, err := in.Client.Search(ctx, in.Keyword)
if err != nil {
return err
}
if len(results) > limit {
results = results[:limit]
}
for i := range results {
if err := emit(&results[i]); err != nil {
return err
}
}
return nil
}
Loading
Loading