Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3c88a83
feat(cli): scaffold go module with cobra command tree
QMalcolm Jun 9, 2026
fcefdc8
chore(cli): add makefile and goreleaser build pipeline
QMalcolm Jun 9, 2026
ac660a0
ci(cli): add github actions workflow for build and test
QMalcolm Jun 9, 2026
5f269d2
test(cli): add unit tests for internal/osidir
QMalcolm Jun 9, 2026
780dcdb
chore(cli): rename osi to ossie throughout cli scaffold
QMalcolm Jun 10, 2026
22e986e
chore(cli): rename internal osidir package to ossiedir
QMalcolm Jun 12, 2026
32955eb
fix(ossiedir): rename defaultOSIDir constant to defaultOssieDir
QMalcolm Jun 23, 2026
c707545
chore(cli): fix .gitignore to ignore cli/ossie build artifact
QMalcolm Jun 23, 2026
7f9fdb7
chore(ossiedir): rename osidir.go and osidir_test.go to ossiedir
QMalcolm Jun 24, 2026
d11a375
feat(cli): add plugin discovery infrastructure
QMalcolm Jun 15, 2026
ca72dc9
test(cli): replace equalStringSlice helper with slices.Equal
QMalcolm Jun 15, 2026
afa1813
fix(cli): use errors.Is for ErrNotExist checks in discover
QMalcolm Jun 15, 2026
7b8bb11
test(cli): use t.TempDir for nonexistent dir test fixture
QMalcolm Jun 15, 2026
8fdac36
test(cli): add test coverage for plugin setup field
QMalcolm Jun 15, 2026
8747de3
docs(cli): document intentional lenient YAML unmarshaling in loadPlugin
QMalcolm Jun 15, 2026
fb5069d
refactor(plugin): rename osi → ossie in ConvertConfig, yaml fields, a…
QMalcolm Jun 23, 2026
bc5abe9
refactor(plugin): drop osi_* backward compat from rawPlugin
QMalcolm Jun 23, 2026
aa56370
feat(plugin): implement basic ossie plugin list command
QMalcolm Jun 23, 2026
e6b0d74
chore(cli): add make install target for local development
QMalcolm Jun 23, 2026
e64ca8f
feat(cli): add plugin invocation protocol
QMalcolm Jun 15, 2026
3c4f5ec
fix(cli): normalise nil Request.Files to empty map before marshaling
QMalcolm Jun 15, 2026
f2f493e
fix(cli): guard against empty invoke slice in Invoke
QMalcolm Jun 15, 2026
f29712e
feat(cli): embed plugin registry
QMalcolm Jun 15, 2026
4c35d2e
docs(cli): address minor review comments in registry package
QMalcolm Jun 15, 2026
0f5d030
feat(plugin): wire registry into ossie plugin list (P1)
QMalcolm Jun 24, 2026
feba7cb
feat(cli): add file collection and LoadPlugin
QMalcolm Jun 16, 2026
9d12f19
feat(cli): embed osi schema for output validation
QMalcolm Jun 16, 2026
cfd2be1
feat(cli): implement osi convert base command
QMalcolm Jun 16, 2026
270b762
chore(cli): remove duplicate schema copy, generate from core-spec
QMalcolm Jun 16, 2026
c8d5c72
test(cli): add test for CollectFiles with empty accepts
QMalcolm Jun 16, 2026
395bb93
fix(cli): skip output dir creation when no files to write
QMalcolm Jun 16, 2026
ee81093
fix(cli): use backticks for inline command in error message
QMalcolm Jun 16, 2026
4c0cb0b
refactor(cli): break runConvert into named helper functions
QMalcolm Jun 16, 2026
88fb91f
fix(convert): update stale to_osi/from_osi references in convert.go
QMalcolm Jun 24, 2026
0eeeb3a
refactor(collect): extract directory walk into collectDir helper
QMalcolm Jun 24, 2026
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
38 changes: 38 additions & 0 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CLI CI

on:
push:
paths:
- 'cli/**'
- '.github/workflows/cli-ci.yml'
pull_request:
paths:
- 'cli/**'
- '.github/workflows/cli-ci.yml'

jobs:
build-and-test:
name: Build and test
runs-on: ubuntu-latest
defaults:
run:
working-directory: cli

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: cli/go.mod
cache-dependency-path: cli/go.sum

- name: make build
run: make build

- name: make lint
run: make lint

- name: make test
run: make test
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
**/__pycache__/
**/.venv/

# Go CLI
cli/dist/
cli/ossie

# Generated from core-spec/osi-schema.json by `make prepare`
cli/internal/schema/osi-schema.json
48 changes: 48 additions & 0 deletions cli/.goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
version: 2

project_name: ossie

before:
hooks:
- go mod tidy

builds:
- id: ossie
main: .
binary: ossie
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
env:
- CGO_ENABLED=0
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}

archives:
- id: ossie
format: tar.gz
format_overrides:
- goos: windows
format: zip
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"

checksum:
name_template: "checksums.txt"

changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
1 change: 1 addition & 0 deletions cli/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
golang 1.26.2
26 changes: 26 additions & 0 deletions cli/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
BINARY_NAME := ossie
BUILD_DIR := dist

.PHONY: prepare build install test lint release-dry-run clean

# Copy the canonical schema from the repo root before any Go step that needs it.
prepare:
cp ../core-spec/osi-schema.json internal/schema/osi-schema.json

build: prepare
go build -o $(BUILD_DIR)/$(BINARY_NAME) .

install: prepare
go build -o $(shell go env GOPATH)/bin/$(BINARY_NAME) .

test: prepare
go test ./...

lint: prepare
go vet ./...

release-dry-run:
goreleaser release --snapshot --clean --config .goreleaser.yaml

clean:
rm -rf $(BUILD_DIR)
248 changes: 248 additions & 0 deletions cli/cmd/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package cmd

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/open-semantic-interchange/ossie/cli/internal/ossiedir"
"github.com/open-semantic-interchange/ossie/cli/internal/plugin"
"github.com/open-semantic-interchange/ossie/cli/internal/schema"
"github.com/spf13/cobra"
)

var convertCmd = &cobra.Command{
Use: "convert --from <platform> --input <path> | --to <platform> --input <path>",
Short: "Convert a semantic model between OSSIE and a platform format",
RunE: runConvert,
}

func init() {
convertCmd.Flags().String("from", "", "Source platform — converts platform → OSSIE")
convertCmd.Flags().String("to", "", "Target platform — converts OSSIE → platform")
convertCmd.Flags().StringP("input", "i", "", "Input file or directory path (required)")
convertCmd.Flags().StringP("output", "o", "", "Output directory path (default: ./ossie-output/<plugin>/<direction>)")
convertCmd.Flags().String("plugin", "", "Path to plugin directory (bypasses name-based discovery)")
convertCmd.Flags().Int("timeout", 60, "Plugin invocation timeout in seconds")
convertCmd.Flags().String("max-input-size", "100MB", "Maximum total input size (e.g. 500MB)")

_ = convertCmd.MarkFlagRequired("input")
convertCmd.MarkFlagsMutuallyExclusive("from", "to")
}

func runConvert(cmd *cobra.Command, args []string) error {
// 1. Parse flags
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
if from == "" && to == "" {
return fmt.Errorf("exactly one of --from or --to must be specified")
}
input, _ := cmd.Flags().GetString("input")
outputFlag, _ := cmd.Flags().GetString("output")
pluginFlag, _ := cmd.Flags().GetString("plugin")
timeoutSecs, _ := cmd.Flags().GetInt("timeout")
maxInputSizeStr, _ := cmd.Flags().GetString("max-input-size")
// verbose is a PersistentFlag on root; Cobra injects it into child flag sets.
verbose, _ := cmd.Flags().GetBool("verbose")

// 2. Resolve plugin
platformName := from
if to != "" {
platformName = to
}
p, err := resolvePlugin(platformName, pluginFlag, cmd.ErrOrStderr())
if err != nil {
return err
}

// 3. Select direction
direction, dir := selectDirection(p, from)

// 4. Parse max-input-size
maxBytes, err := parseMaxInputSize(maxInputSizeStr)
if err != nil {
return fmt.Errorf("invalid --max-input-size %q: %w", maxInputSizeStr, err)
}

// 5. Collect input files
files, err := plugin.CollectFiles(input, dir.Accepts, maxBytes)
if err != nil {
return fmt.Errorf("input collection failed: %w", err)
}

// 6. Build context with timeout
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second)
defer cancel()

// 7. Route plugin stderr
var pluginStderr io.Writer = io.Discard
if verbose {
pluginStderr = cmd.ErrOrStderr()
}

// 8. Invoke plugin
resp, err := plugin.Invoke(ctx, p.Path, dir.Invoke, plugin.Request{Files: files}, pluginStderr)
if err != nil {
return fmt.Errorf("plugin invocation failed: %w", err)
}

// 9. Validate output
resp.Issues = append(resp.Issues, validateOutput(resp, direction)...)

// 10. Write output files
outDir := outputFlag
if outDir == "" {
outDir = filepath.Join("ossie-output", p.Platform.Name, direction)
}
if err := writeOutput(resp.Files, outDir); err != nil {
return err
}

// 11. Render issues and set exit code
if hasErrors := renderIssues(resp.Issues, cmd.ErrOrStderr(), verbose); hasErrors {
return fmt.Errorf("conversion completed with errors")
}
return nil
}

// resolvePlugin finds the plugin to use for the conversion.
// If pluginFlag is set, it loads from that path directly. Otherwise it
// discovers all installed plugins and filters by platformName.
func resolvePlugin(platformName, pluginFlag string, stderr io.Writer) (*plugin.Plugin, error) {
if pluginFlag != "" {
loaded, err := plugin.LoadPlugin(pluginFlag)
if err != nil {
return nil, fmt.Errorf("could not load plugin from %q: %w", pluginFlag, err)
}
return loaded, nil
}

pluginsDir, err := ossiedir.PluginDir()
if err != nil {
return nil, err
}
plugins, err := plugin.Discover(pluginsDir, stderr)
if err != nil {
return nil, err
}

var matches []*plugin.Plugin
for _, candidate := range plugins {
if candidate.Platform.Name == platformName {
matches = append(matches, candidate)
}
}
switch len(matches) {
case 0:
return nil, fmt.Errorf("no plugin found for %q; run `ossie plugin install %s` to install it", platformName, platformName)
case 1:
return matches[0], nil
default:
return nil, fmt.Errorf("multiple plugins found for %q; use --plugin to specify a path", platformName)
}
}

// selectDirection returns the direction string ("to_ossie" or "from_ossie") and
// the corresponding Direction based on whether --from or --to was specified.
func selectDirection(p *plugin.Plugin, from string) (string, plugin.Direction) {
if from != "" {
return "to_ossie", p.Convert.ToOssie
}
return "from_ossie", p.Convert.FromOssie
}

// validateOutput validates to_ossie response files against the embedded OSI schema.
// Invalid files are removed from resp.Files in place and returned as
// error-severity issues for the caller to append to resp.Issues.
func validateOutput(resp *plugin.Response, direction string) []plugin.Issue {
if direction != "to_ossie" {
return nil
}
var issues []plugin.Issue
for name, content := range resp.Files {
if verr := schema.Validate([]byte(content)); verr != nil {
issues = append(issues, plugin.Issue{
Severity: "error",
Path: name,
Message: fmt.Sprintf("output validation failed: %v", verr),
})
delete(resp.Files, name)
}
}
return issues
}

// writeOutput writes files to outDir, creating the directory only when there
// are files to write. Subdirectories within outDir are created as needed.
func writeOutput(files map[string]string, outDir string) error {
if len(files) == 0 {
return nil
}
if err := os.MkdirAll(outDir, 0755); err != nil {
return fmt.Errorf("could not create output directory %q: %w", outDir, err)
}
for name, content := range files {
dest := filepath.Join(outDir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return fmt.Errorf("could not create output subdirectory for %q: %w", name, err)
}
if err := os.WriteFile(dest, []byte(content), 0644); err != nil {
return fmt.Errorf("could not write output file %q: %w", dest, err)
}
}
return nil
}

// parseMaxInputSize parses a size string like "100MB", "2GB", or a raw integer
// (interpreted as bytes). The suffix is case-insensitive.
func parseMaxInputSize(s string) (int64, error) {
upper := strings.ToUpper(strings.TrimSpace(s))
if strings.HasSuffix(upper, "GB") {
n, err := strconv.ParseInt(strings.TrimSuffix(upper, "GB"), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid GB value: %w", err)
}
return n * 1024 * 1024 * 1024, nil
}
if strings.HasSuffix(upper, "MB") {
n, err := strconv.ParseInt(strings.TrimSuffix(upper, "MB"), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid MB value: %w", err)
}
return n * 1024 * 1024, nil
}
n, err := strconv.ParseInt(upper, 10, 64)
if err != nil {
return 0, fmt.Errorf("unsupported size format %q (use e.g. 100MB, 2GB, or a raw byte count)", s)
}
return n, nil
}

// renderIssues writes issues to w and returns true if any error-severity issue
// is present. info-severity issues are suppressed unless verbose is true.
func renderIssues(issues []plugin.Issue, w io.Writer, verbose bool) bool {
hasErrors := false
for _, iss := range issues {
if iss.Severity == "info" && !verbose {
continue
}
if iss.Path != "" {
fmt.Fprintf(w, "%s: %s: %s\n", iss.Severity, iss.Path, iss.Message)
} else {
fmt.Fprintf(w, "%s: %s\n", iss.Severity, iss.Message)
}
if iss.Severity == "error" {
hasErrors = true
}
}
return hasErrors
}
Loading
Loading