Skip to content

Commit 66ac59b

Browse files
committed
add modules
1 parent 03520fa commit 66ac59b

File tree

15 files changed

+252
-54
lines changed

15 files changed

+252
-54
lines changed

.claude/skills/dotular-module/SKILL.md

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ description: Use when user asks to create a dotular module, add a tool to dotula
77

88
Create a complete dotular registry module for a tool by researching its config files, scanning the local machine, and writing the module YAML.
99

10+
## Critical Rule
11+
12+
1. **NEVER use `dotular add`**. That command writes to the user's personal `dotular.yaml` and only sets the destination for the current platform. This skill creates **registry modules** — standalone YAML files in `modules/` with cross-platform destinations. Always create files manually as described in Step 4.
13+
14+
2. **NEVER copy personal config files into registry modules**. Registry modules are shared/generic — they define package items and file destination mappings only. The `file:` items tell dotular *where* a config file belongs, but the actual config content comes from the user's own store, not the registry module. Do NOT create a `modules/<tool>/` subdirectory or copy files like `~/.config/tool/config.toml` into it.
15+
1016
## Process
1117

1218
```dot
@@ -56,7 +62,7 @@ ls -la ~/.config/toolname/ 2>/dev/null
5662
ls -la ~/Library/Application\ Support/ToolName/ 2>/dev/null
5763
```
5864

59-
Record which files/directories exist. This validates your research and identifies files available to copy into the module store.
65+
Record which files/directories exist. This validates your research and confirms the correct destination paths for the `file:` items in the module YAML. Do NOT copy these files — the scan is only to verify paths.
6066

6167
## Step 3: Present Summary
6268

@@ -97,47 +103,34 @@ Include a clear recommendation. Wait for user confirmation before proceeding.
97103

98104
## Step 4: Execute
99105

100-
After user confirms:
101-
102-
1. **Create module directory** (if config files exist to store):
103-
```bash
104-
mkdir -p modules/<tool>/
105-
```
106-
107-
2. **Copy existing config files** into the module store:
108-
```bash
109-
cp ~/.config/tool/config.yml modules/<tool>/config.yml
110-
```
111-
File placement rule: files go at `modules/<tool>/<filename>` where `<filename>` matches the `file:` value in the YAML. The runner prepends the module name as `sourcePrefix`.
112-
113-
3. **Write module YAML** at `modules/<tool>.yaml`:
114-
115-
```yaml
116-
name: <tool-name>
117-
version: "1.0.0"
118-
items:
119-
- package: <name>
120-
via: brew
121-
skip_if: command -v <name>
122-
verify: <name> --version
123-
124-
- package: <name>
125-
via: apt
126-
skip_if: command -v <name>
127-
128-
- package: <name-or-id>
129-
via: winget
130-
131-
- file: <filename>
132-
destination:
133-
macos: <path>
134-
linux: <path>
135-
windows: <path>
136-
```
106+
After user confirms, **write the module YAML** at `modules/<tool>.yaml`:
107+
108+
```yaml
109+
name: <tool-name>
110+
version: "1.0.0"
111+
items:
112+
- package: <name>
113+
via: brew
114+
skip_if: command -v <name>
115+
verify: <name> --version
116+
117+
- package: <name>
118+
via: apt
119+
skip_if: command -v <name>
120+
121+
- package: <name-or-id>
122+
via: winget
123+
124+
- file: <filename>
125+
destination:
126+
macos: <path>
127+
linux: <path>
128+
windows: <path>
129+
```
137130
138-
**Multiple package items**: Create a separate `package` item for each platform's package manager (see `dotular.yaml` VS Code module for this pattern). Use `skip_if: command -v <tool>` so only the available manager runs. The runner executes all items — an unavailable package manager simply fails, but `skip_if` prevents redundant installs after the first succeeds.
131+
**Multiple package items**: Create a separate `package` item for each platform's package manager (see `dotular.yaml` VS Code module for this pattern). Use `skip_if: command -v <tool>` so only the available manager runs. The runner executes all items — an unavailable package manager simply fails, but `skip_if` prevents redundant installs after the first succeeds.
139132

140-
4. If **no config files** to store (package-only module), skip creating the subdirectory.
133+
**File items**: The `file:` and `directory:` items define destination mappings only — where config files belong on each platform. Do NOT create a `modules/<tool>/` subdirectory or copy personal config files into it. The user's own dotular store provides the actual file content at apply time.
141134

142135
## Step 5: Verify
143136

@@ -172,6 +165,8 @@ Display the complete contents of the generated `modules/<tool>.yaml` for user re
172165
| Missing `skip_if` on package items | Add `skip_if: command -v <tool>` for idempotency |
173166
| Using trailing `/` in destination | Match existing convention: `~/.config/tool` not `~/.config/tool/` |
174167
| Forgetting Windows paths | Always research all 3 platforms for PlatformMap |
168+
| Using `dotular add` command | NEVER — it writes to personal `dotular.yaml` with single-platform destinations. Manually create `modules/<tool>.yaml` instead |
169+
| Copying personal config files into module | NEVER — registry modules define destination mappings only, not file content. Do not create `modules/<tool>/` subdirectories or `cp` user configs into them |
175170
| Editing user's `dotular.yaml` | This skill creates REGISTRY modules only — never touch personal config |
176171
| Updating `modules/index.yaml` | Out of scope — tell user to add the entry manually |
177172

.github/workflows/test.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,19 @@ jobs:
4242
4343
- name: Vet
4444
run: go vet ./...
45+
46+
- name: Verify modules index
47+
run: |
48+
# Generate index.yaml from module files
49+
echo "modules:" > /tmp/index.yaml
50+
for f in modules/*.yaml; do
51+
[ "$(basename "$f")" = "index.yaml" ] && continue
52+
name=$(grep '^name:' "$f" | head -1 | sed 's/^name: *//')
53+
version=$(grep '^version:' "$f" | head -1 | sed 's/^version: *//')
54+
echo " - name: $name" >> /tmp/index.yaml
55+
echo " version: $version" >> /tmp/index.yaml
56+
done
57+
if ! diff -u modules/index.yaml /tmp/index.yaml; then
58+
echo "::error::modules/index.yaml is out of date. Run 'make index' to regenerate."
59+
exit 1
60+
fi

Makefile

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
BIN := dotular
22
CMD := ./cmd/dotular
33

4-
.PHONY: build run tidy clean
4+
.PHONY: build run tidy clean index
55

66
build:
77
go build -o ./build/$(BIN) $(CMD)
@@ -24,3 +24,14 @@ test-status:
2424

2525
test-apply-dry:
2626
go run $(CMD) apply --dry-run
27+
28+
index:
29+
@echo "modules:" > modules/index.yaml
30+
@for f in modules/*.yaml; do \
31+
[ "$$(basename "$$f")" = "index.yaml" ] && continue; \
32+
name=$$(grep '^name:' "$$f" | head -1 | sed 's/^name: *//'); \
33+
version=$$(grep '^version:' "$$f" | head -1 | sed 's/^version: *//'); \
34+
echo " - name: $$name" >> modules/index.yaml; \
35+
echo " version: $$version" >> modules/index.yaml; \
36+
done
37+
@echo "Generated modules/index.yaml"

cmd/dotular/main.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -702,11 +702,14 @@ func registryCmd() *cobra.Command {
702702
Short: "Manage the local registry cache",
703703
}
704704

705-
cmd.AddCommand(
706-
&cobra.Command{
707-
Use: "list",
708-
Short: "List cached registry modules",
709-
RunE: func(cmd *cobra.Command, args []string) error {
705+
listCmd := &cobra.Command{
706+
Use: "list",
707+
Short: "List available registry modules",
708+
RunE: func(cmd *cobra.Command, args []string) error {
709+
cached, _ := cmd.Flags().GetBool("cached")
710+
u := ui.New(os.Stdout, os.Stderr)
711+
712+
if cached {
710713
_, err := loadConfig()
711714
if err != nil {
712715
return err
@@ -716,7 +719,6 @@ func registryCmd() *cobra.Command {
716719
if err != nil {
717720
return err
718721
}
719-
u := ui.New(os.Stdout, os.Stderr)
720722
if len(lock.Registry) == 0 {
721723
u.Info("(no cached registry modules)")
722724
return nil
@@ -726,7 +728,6 @@ func registryCmd() *cobra.Command {
726728
for ref, entry := range lock.Registry {
727729
ref := registry.ParseRef(ref)
728730
trustStr := ref.Trust.String()
729-
// Pre-color trust
730731
switch trustStr {
731732
case "official":
732733
trustStr = color.BoldGreen(trustStr)
@@ -743,8 +744,31 @@ func registryCmd() *cobra.Command {
743744
}
744745
u.Table(headers, rows, nil)
745746
return nil
746-
},
747+
}
748+
749+
// Default: fetch and display remote index.
750+
ctx := context.Background()
751+
entries, err := registry.FetchIndex(ctx, u)
752+
if err != nil {
753+
return err
754+
}
755+
if len(entries) == 0 {
756+
u.Info("(no modules in registry)")
757+
return nil
758+
}
759+
headers := []string{"NAME", "VERSION"}
760+
var rows [][]string
761+
for _, e := range entries {
762+
rows = append(rows, []string{e.Name, e.Version})
763+
}
764+
u.Table(headers, rows, nil)
765+
return nil
747766
},
767+
}
768+
listCmd.Flags().Bool("cached", false, "Show locally cached modules instead of the remote index")
769+
770+
cmd.AddCommand(
771+
listCmd,
748772
&cobra.Command{
749773
Use: "clear",
750774
Short: "Remove all cached registry modules",

dotular

-32 Bytes
Binary file not shown.

internal/actions/action.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
package actions
22

3-
import "context"
3+
import (
4+
"context"
5+
"errors"
6+
)
7+
8+
// ErrSkipped is returned by an action's Run method when the action cannot
9+
// proceed but the failure is not an error (e.g. pulling a file that does not
10+
// exist on the system). The runner treats this as a skip rather than a failure.
11+
var ErrSkipped = errors.New("skipped")
412

513
// Action is a single executable step produced from a config item.
614
type Action interface {

internal/actions/directory.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ func (a *DirectoryAction) Run(ctx context.Context, dryRun bool) error {
108108

109109
switch a.Direction {
110110
case "pull":
111+
if !dirExists(target) {
112+
return fmt.Errorf("pull: system directory does not exist: %s: %w", target, ErrSkipped)
113+
}
111114
return copyDir(target, a.Source)
112115
case "sync":
113116
repoExists := dirExists(a.Source)

internal/actions/file.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func (a *FileAction) runPush(destDir, target string) error {
169169

170170
func (a *FileAction) runPull(target string) error {
171171
if _, err := os.Stat(target); os.IsNotExist(err) {
172-
return fmt.Errorf("pull: system file does not exist: %s", target)
172+
return fmt.Errorf("pull: system file does not exist: %s: %w", target, ErrSkipped)
173173
}
174174
if err := os.MkdirAll(filepath.Dir(a.Source), 0o755); err != nil {
175175
return fmt.Errorf("create repo directory: %w", err)

internal/registry/fetch.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ func Fetch(ctx context.Context, rawRef string, lock *LockFile, noCache bool, u *
4848
return nil, ref.Trust, fmt.Errorf("fetch %s: %w", rawRef, err)
4949
}
5050

51-
// Verify against existing lockfile entry (if any).
51+
// Verify against existing lockfile entry when using cache; skip when
52+
// explicitly re-fetching so that updated modules are accepted.
5253
sum := fmt.Sprintf("%x", sha256.Sum256(data))
53-
if inLock && entry.SHA256 != sum {
54+
if !noCache && inLock && entry.SHA256 != sum {
5455
return nil, ref.Trust, fmt.Errorf(
5556
"registry: checksum mismatch for %s after re-fetch (lockfile: %s, got: %s)",
5657
rawRef, entry.SHA256, sum,

internal/runner/runner.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package runner
44

55
import (
66
"context"
7+
"errors"
78
"fmt"
89
"io"
910
"os"
@@ -340,6 +341,14 @@ func (r *Runner) applyItem(ctx context.Context, mod config.Module, item config.I
340341

341342
start := time.Now()
342343
runErr := action.Run(ctx, false)
344+
345+
if runErr != nil && errors.Is(runErr, actions.ErrSkipped) {
346+
msg := strings.TrimSuffix(runErr.Error(), ": "+actions.ErrSkipped.Error())
347+
r.UI.Skip(msg, action.Describe())
348+
audit.Log(audit.Entry{Command: r.Command, Module: mod.Name, Item: action.Describe(), Outcome: "skipped"})
349+
return outcomeSkipped, nil
350+
}
351+
343352
r.UI.ItemResult(action.Describe(), time.Since(start), runErr)
344353

345354
outcome, errMsg := "success", ""

0 commit comments

Comments
 (0)