Skip to content
Draft
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
210 changes: 210 additions & 0 deletions internal/cmd/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
)

// appsLinkDir and appsLinkFile name the local file that records which
// remote custom app a directory is linked to, mirroring how `vercel link`/
// `netlify link` avoid needing a server-side unique name to upsert against.
const (
appsLinkDir = ".langsmith"
appsLinkFile = "app.json"
)

const (
appsMaxFileEntries = 500
appsMaxFileSizeBytes = 1 << 20 // 1MB per file, matches hub push's limit.
)

// appLink is the contents of <dir>/.langsmith/app.json. It is the durable
// record of "what this app is" that `apps push` reads on every run to decide
// whether to create a new custom app or update the one it created last time.
type appLink struct {
AppID string `json:"app_id"`
Name string `json:"name"`
ContextType string `json:"context_type,omitempty"`
}

// customApp mirrors the JSON shape returned by smith-go's
// /v1/platform/custom-apps endpoints (smith-go/customapps/types.go).
type customApp struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
ContextType string `json:"context_type"`
Files map[string]string `json:"files,omitempty"`
Entrypoint string `json:"entrypoint"`
IsEnabled bool `json:"is_enabled"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
CreatedBy *string `json:"created_by,omitempty"`
}

var appsExcludedDirs = map[string]bool{
".git": true,
"node_modules": true,
".langsmith": true,
".cache": true,
".next": true,
}

var appsExcludedFiles = map[string]bool{
".DS_Store": true,
"Thumbs.db": true,
}

func newAppsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "apps",
Short: "Build, upload, and manage Custom Apps",
Long: `Build, upload, and manage Custom Apps — UIs you build locally against the
LangSmith API and run inside LangSmith, either standalone (Custom Apps tab)
or embedded in a specific resource (e.g. an annotation queue).

A custom app is a small set of files rendered in a locked-down sandbox: no
direct network access, no npm modules at runtime — only a postMessage
bridge (window.langsmith.call/setData) proxied through the viewer's own
LangSmith session. Use React/JSX or npm dependencies freely; the scaffolded
starter bundles them locally (esbuild for --type standalone, Vite for
--type annotation-queue) into the single dependency-free file the sandbox
expects.

Examples:
langsmith apps init --name my-app --type standalone
langsmith apps init --name my-queue-app --type annotation-queue
langsmith apps dev
langsmith apps push
langsmith apps list
langsmith apps delete <app-id> --yes

All of the above (except list/delete, which take an app ID) act on the
current directory — cd into your app's directory first.`,
}
cmd.AddCommand(newAppsInitCmd())
cmd.AddCommand(newAppsDevCmd())
cmd.AddCommand(newAppsPushCmd())
cmd.AddCommand(newAppsListCmd())
cmd.AddCommand(newAppsDeleteCmd())
return cmd
}

// readAppLink reads <dir>/.langsmith/app.json. It returns (nil, nil) if the
// file does not exist — callers treat that as "not linked yet".
func readAppLink(dir string) (*appLink, error) {
path := filepath.Join(dir, appsLinkDir, appsLinkFile)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var link appLink
if err := json.Unmarshal(data, &link); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return &link, nil
}

// writeAppLink writes <dir>/.langsmith/app.json, creating the directory if
// needed.
func writeAppLink(dir string, link appLink) error {
linkDir := filepath.Join(dir, appsLinkDir)
if err := os.MkdirAll(linkDir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", linkDir, err)
}
data, err := json.MarshalIndent(link, "", " ")
if err != nil {
return fmt.Errorf("marshaling app link: %w", err)
}
data = append(data, '\n')
path := filepath.Join(linkDir, appsLinkFile)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}

// readDirectoryAsAppFiles walks root and returns a flat relative-path →
// content map suitable for custom_apps.files. Unlike hub's equivalent
// (readDirectoryAsFiles in hub_push.go), there is no {type, content}
// wrapper — the custom-apps API takes a plain map[string]string — and
// .langsmith/ itself is always excluded so the link file never gets
// uploaded as an app file.
func readDirectoryAsAppFiles(root string) (map[string]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", root)
}

files := make(map[string]string)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
base := d.Name()
if d.IsDir() {
if appsExcludedDirs[base] {
return filepath.SkipDir
}
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
return nil
}
if appsExcludedFiles[base] || strings.HasPrefix(base, ".env") {
return nil
}

rel = filepath.ToSlash(rel)

data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", rel, err)
}
if len(data) > appsMaxFileSizeBytes {
return fmt.Errorf("file %s is %d bytes; exceeds %d-byte limit", rel, len(data), appsMaxFileSizeBytes)
}
if isAppFileBinary(data) {
return fmt.Errorf("file %s appears to be binary; custom apps do not support binary assets yet", rel)
}

files[rel] = string(data)
if len(files) > appsMaxFileEntries {
return fmt.Errorf("directory contains more than %d files; reduce before pushing", appsMaxFileEntries)
}
return nil
})
if err != nil {
return nil, err
}
return files, nil
}

func isAppFileBinary(data []byte) bool {
n := len(data)
if n > 512 {
n = 512
}
return bytes.IndexByte(data[:n], 0) != -1
}
61 changes: 61 additions & 0 deletions internal/cmd/apps_crud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cmd

import (
"encoding/json"
"net/http"
"strings"
"testing"
)

func TestAppsList_PassesContextTypeFilter(t *testing.T) {
var sawQuery string
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
sawQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]customApp{
{ID: "a1", Name: "one", ContextType: "annotation_queue"},
})
})
defer setupTestEnv(t, srv.URL)()

out := captureStdout(t, func() {
cmd := newAppsCmd()
cmd.SetArgs([]string{"list", "--context-type", "annotation_queue"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})

if sawQuery != "context_type=annotation_queue" {
t.Errorf("unexpected query: %q", sawQuery)
}
if !strings.Contains(out, `"id": "a1"`) {
t.Errorf("expected app in output:\n%s", out)
}
}

func TestAppsDelete_SkipsConfirmationWithYes(t *testing.T) {
var sawDelete bool
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/v1/platform/custom-apps/a1" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
sawDelete = true
w.WriteHeader(http.StatusNoContent)
})
defer setupTestEnv(t, srv.URL)()

out := captureStdout(t, func() {
cmd := newAppsCmd()
cmd.SetArgs([]string{"delete", "a1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
if !sawDelete {
t.Error("expected DELETE request")
}
if !strings.Contains(out, `"status": "deleted"`) {
t.Errorf("expected deleted status:\n%s", out)
}
}
48 changes: 48 additions & 0 deletions internal/cmd/apps_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
"strings"

"github.com/langchain-ai/langsmith-cli/internal/output"
"github.com/spf13/cobra"
)

func newAppsDeleteCmd() *cobra.Command {
var yes bool

cmd := &cobra.Command{
Use: "delete APP_ID",
Short: "Delete a custom app",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
if !yes {
fmt.Fprintf(os.Stderr, "Delete custom app '%s'? [y/N] ", id)
var confirm string
_, _ = fmt.Scanln(&confirm)
if strings.ToLower(confirm) != "y" {
return errors.New("aborted")
}
}

c := MustGetClient()
ctx := context.Background()

if err := c.RawDelete(ctx, "/v1/platform/custom-apps/"+id, nil); err != nil {
return fmt.Errorf("deleting custom app %s: %w", id, err)
}
output.OutputJSON(map[string]any{
"status": "deleted",
"app_id": id,
}, "")
return nil
},
}

cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt")
return cmd
}
Loading
Loading