tenseleyflow/shtick / 6f78635

Browse files

sure is a lot better than the C version

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6f7863550ab879a0703d61d02f960d4238f2ce8c
Parents
a78c6f2
Tree
7f21d35

4 changed files

StatusFile+-
A src/shtick/config.py 176 0
A src/shtick/generator.py 109 0
A src/shtick/shells.py 130 0
A src/shtick/shtick.py 227 0
src/shtick/config.pyadded
@@ -0,0 +1,176 @@
1
+"""
2
+Configuration parsing for shtick
3
+"""
4
+
5
+import os
6
+import tomllib
7
+from pathlib import Path
8
+from dataclasses import dataclass
9
+from typing import Dict, List, Optional
10
+
11
+
12
+@dataclass
13
+class GroupData:
14
+    """Holds parsed data for a single group"""
15
+
16
+    name: str
17
+    aliases: Dict[str, str]
18
+    env_vars: Dict[str, str]
19
+    functions: Dict[str, str]
20
+
21
+
22
+class Config:
23
+    """Main configuration handler"""
24
+
25
+    def __init__(self, config_path: Optional[str] = None):
26
+        self.config_path = config_path or self.get_default_config_path()
27
+        self.groups: List[GroupData] = []
28
+
29
+    @staticmethod
30
+    def get_default_config_path() -> str:
31
+        """Get the default config file location"""
32
+        return os.path.expanduser("~/.config/shtick/config.toml")
33
+
34
+    @staticmethod
35
+    def get_output_dir() -> str:
36
+        """Get the output directory for generated shell files"""
37
+        return os.path.expanduser("~/.config/shtick")
38
+
39
+    def ensure_config_dir(self) -> None:
40
+        """Ensure the config directory exists"""
41
+        config_dir = os.path.dirname(self.config_path)
42
+        Path(config_dir).mkdir(parents=True, exist_ok=True)
43
+
44
+    def load(self) -> None:
45
+        """Load and parse the TOML configuration file"""
46
+        if not os.path.exists(self.config_path):
47
+            raise FileNotFoundError(f"Config file not found: {self.config_path}")
48
+
49
+        with open(self.config_path, "rb") as f:
50
+            data = tomllib.load(f)
51
+
52
+        self.groups = []
53
+
54
+        # Parse groups from TOML structure like [group1.aliases]
55
+        group_data = {}
56
+
57
+        for key, value in data.items():
58
+            if "." in key:
59
+                group_name, data_type = key.split(".", 1)
60
+
61
+                if group_name not in group_data:
62
+                    group_data[group_name] = {
63
+                        "aliases": {},
64
+                        "env_vars": {},
65
+                        "functions": {},
66
+                    }
67
+
68
+                if data_type == "aliases":
69
+                    group_data[group_name]["aliases"] = value
70
+                elif data_type == "env_vars":
71
+                    group_data[group_name]["env_vars"] = value
72
+                elif data_type == "functions":
73
+                    group_data[group_name]["functions"] = value
74
+
75
+        # Convert to GroupData objects
76
+        for group_name, data in group_data.items():
77
+            self.groups.append(
78
+                GroupData(
79
+                    name=group_name,
80
+                    aliases=data["aliases"],
81
+                    env_vars=data["env_vars"],
82
+                    functions=data["functions"],
83
+                )
84
+            )
85
+
86
+    def save(self) -> None:
87
+        """Save the current configuration back to TOML file"""
88
+        self.ensure_config_dir()
89
+
90
+        # Convert groups back to TOML structure
91
+        data = {}
92
+        for group in self.groups:
93
+            if group.aliases:
94
+                data[f"{group.name}.aliases"] = group.aliases
95
+            if group.env_vars:
96
+                data[f"{group.name}.env_vars"] = group.env_vars
97
+            if group.functions:
98
+                data[f"{group.name}.functions"] = group.functions
99
+
100
+        # Write TOML manually since tomllib is read-only
101
+        with open(self.config_path, "w") as f:
102
+            for section, items in data.items():
103
+                f.write(f"[{section}]\n")
104
+                for key, value in items.items():
105
+                    # Escape quotes in values
106
+                    escaped_value = value.replace('"', '\\"')
107
+                    f.write(f'{key} = "{escaped_value}"\n')
108
+                f.write("\n")
109
+
110
+    def get_group(self, group_name: str) -> Optional[GroupData]:
111
+        """Get a specific group by name"""
112
+        for group in self.groups:
113
+            if group.name == group_name:
114
+                return group
115
+        return None
116
+
117
+    def add_group(self, group_name: str) -> GroupData:
118
+        """Add a new group or return existing one"""
119
+        existing = self.get_group(group_name)
120
+        if existing:
121
+            return existing
122
+
123
+        new_group = GroupData(name=group_name, aliases={}, env_vars={}, functions={})
124
+        self.groups.append(new_group)
125
+        return new_group
126
+
127
+    def add_item(self, item_type: str, group_name: str, key: str, value: str) -> None:
128
+        """Add an alias, env var, or function to a group"""
129
+        group = self.add_group(group_name)
130
+
131
+        if item_type == "alias":
132
+            group.aliases[key] = value
133
+        elif item_type == "env":
134
+            group.env_vars[key] = value
135
+        elif item_type == "function":
136
+            group.functions[key] = value
137
+        else:
138
+            raise ValueError(f"Unknown item type: {item_type}")
139
+
140
+    def remove_item(self, item_type: str, group_name: str, key: str) -> bool:
141
+        """Remove an item from a group. Returns True if found and removed."""
142
+        group = self.get_group(group_name)
143
+        if not group:
144
+            return False
145
+
146
+        if item_type == "alias" and key in group.aliases:
147
+            del group.aliases[key]
148
+            return True
149
+        elif item_type == "env" and key in group.env_vars:
150
+            del group.env_vars[key]
151
+            return True
152
+        elif item_type == "function" and key in group.functions:
153
+            del group.functions[key]
154
+            return True
155
+
156
+        return False
157
+
158
+    def find_items(
159
+        self, item_type: str, group_name: str, search_term: str
160
+    ) -> List[str]:
161
+        """Find items matching a search term (for fuzzy removal)"""
162
+        group = self.get_group(group_name)
163
+        if not group:
164
+            return []
165
+
166
+        if item_type == "alias":
167
+            items = group.aliases.keys()
168
+        elif item_type == "env":
169
+            items = group.env_vars.keys()
170
+        elif item_type == "function":
171
+            items = group.functions.keys()
172
+        else:
173
+            return []
174
+
175
+        # Simple fuzzy matching - contains search term
176
+        return [item for item in items if search_term.lower() in item.lower()]
src/shtick/generator.pyadded
@@ -0,0 +1,109 @@
1
+"""
2
+Shell file generator for shtick
3
+"""
4
+
5
+import os
6
+from pathlib import Path
7
+from typing import Dict, List
8
+from shtick.config import GroupData, Config
9
+from shtick.shells import get_supported_shells, get_shell_syntax
10
+
11
+
12
+class Generator:
13
+    """Generates shell configuration files from parsed data"""
14
+
15
+    def __init__(self, output_base_dir: str = None):
16
+        self.output_base_dir = output_base_dir or Config.get_output_dir()
17
+
18
+    def ensure_output_dir(self, group_name: str, item_type: str) -> str:
19
+        """Ensure output directory exists and return the path"""
20
+        output_dir = os.path.join(self.output_base_dir, group_name, item_type)
21
+        Path(output_dir).mkdir(parents=True, exist_ok=True)
22
+        return output_dir
23
+
24
+    def generate_for_group(self, group: GroupData) -> None:
25
+        """Generate all shell files for a single group"""
26
+        print(f"Processing group: {group.name}")
27
+
28
+        # Generate aliases
29
+        if group.aliases:
30
+            print(f"  Generating alias files ({len(group.aliases)} aliases)")
31
+            self._generate_files(group.name, "alias", group.aliases, "aliases")
32
+
33
+        # Generate env vars
34
+        if group.env_vars:
35
+            print(f"  Generating env var files ({len(group.env_vars)} variables)")
36
+            self._generate_files(group.name, "env", group.env_vars, "envvars")
37
+
38
+        # Generate functions
39
+        if group.functions:
40
+            print(f"  Generating function files ({len(group.functions)} functions)")
41
+            self._generate_files(group.name, "function", group.functions, "functions")
42
+
43
+    def _generate_files(
44
+        self, group_name: str, item_type: str, items: Dict[str, str], prefix: str
45
+    ) -> None:
46
+        """Generate shell files for a specific item type"""
47
+        output_dir = self.ensure_output_dir(group_name, item_type)
48
+
49
+        # Generate for each supported shell + default
50
+        all_shells = get_supported_shells() + ["default"]
51
+
52
+        for shell_name in all_shells:
53
+            shell_syntax = get_shell_syntax(shell_name)
54
+            filename = f"{prefix}.{shell_name}"
55
+            filepath = os.path.join(output_dir, filename)
56
+
57
+            with open(filepath, "w") as f:
58
+                # Write header
59
+                f.write(f"# {prefix} for {shell_name}\n")
60
+                f.write("# Generated by shtick\n\n")
61
+
62
+                # Write items using appropriate syntax
63
+                for key, value in items.items():
64
+                    if item_type == "alias":
65
+                        line = shell_syntax.alias_fmt.format(key, value)
66
+                    elif item_type == "env":
67
+                        line = shell_syntax.env_fmt.format(key, value)
68
+                    elif item_type == "function":
69
+                        line = shell_syntax.function_fmt.format(key, value)
70
+                    else:
71
+                        continue
72
+
73
+                    f.write(line)
74
+
75
+    def generate_all(self, config: Config) -> None:
76
+        """Generate shell files for all groups in config"""
77
+        if not config.groups:
78
+            print("No groups found in configuration")
79
+            return
80
+
81
+        print(f"Generating shell files for {len(config.groups)} groups...")
82
+
83
+        for group in config.groups:
84
+            self.generate_for_group(group)
85
+
86
+        print(f"All done! Files generated in {self.output_base_dir}")
87
+        print("\nTo use these files, add lines like this to your shell config:")
88
+        print("  # For bash/zsh:")
89
+        print("  source ~/.config/shtick/GROUP_NAME/alias/aliases.bash")
90
+        print("  source ~/.config/shtick/GROUP_NAME/env/envvars.bash")
91
+        print("  source ~/.config/shtick/GROUP_NAME/function/functions.bash")
92
+        print("\n  # For fish:")
93
+        print("  source ~/.config/shtick/GROUP_NAME/alias/aliases.fish")
94
+        print("  # etc...")
95
+
96
+    def get_shell_files_for_group(self, group_name: str) -> Dict[str, List[str]]:
97
+        """Get list of generated shell files for a group"""
98
+        files = {"alias": [], "env": [], "function": []}
99
+
100
+        for item_type in files.keys():
101
+            type_dir = os.path.join(self.output_base_dir, group_name, item_type)
102
+            if os.path.exists(type_dir):
103
+                files[item_type] = [
104
+                    os.path.join(type_dir, f)
105
+                    for f in os.listdir(type_dir)
106
+                    if os.path.isfile(os.path.join(type_dir, f))
107
+                ]
108
+
109
+        return files
src/shtick/shells.pyadded
@@ -0,0 +1,130 @@
1
+"""
2
+Shell syntax definitions for shtick
3
+"""
4
+
5
+
6
+class ShellSyntax:
7
+    """Holds syntax patterns for different shell types"""
8
+
9
+    def __init__(self, name, alias_fmt, env_fmt, function_fmt):
10
+        self.name = name
11
+        self.alias_fmt = alias_fmt
12
+        self.env_fmt = env_fmt
13
+        self.function_fmt = function_fmt
14
+
15
+
16
+# Shell syntax table - using the most natural syntax for each type
17
+SHELLS = {
18
+    "bash": ShellSyntax(
19
+        "bash",
20
+        alias_fmt="alias {}='{}'\n",
21
+        env_fmt="export {}='{}'\n",
22
+        function_fmt="{}() {{\n    {}\n}}\n",
23
+    ),
24
+    "zsh": ShellSyntax(
25
+        "zsh",
26
+        alias_fmt="alias {}='{}'\n",
27
+        env_fmt="export {}='{}'\n",
28
+        function_fmt="{}() {{\n    {}\n}}\n",
29
+    ),
30
+    "fish": ShellSyntax(
31
+        "fish",
32
+        alias_fmt="alias {} '{}'\n",
33
+        env_fmt="set -x {} '{}'\n",
34
+        function_fmt="function {}\n    {}\nend\n",
35
+    ),
36
+    "ksh": ShellSyntax(
37
+        "ksh",
38
+        alias_fmt="alias {}='{}'\n",
39
+        env_fmt="export {}='{}'\n",
40
+        function_fmt="{}() {{\n    {}\n}}\n",
41
+    ),
42
+    "mksh": ShellSyntax(
43
+        "mksh",
44
+        alias_fmt="alias {}='{}'\n",
45
+        env_fmt="export {}='{}'\n",
46
+        function_fmt="{}() {{\n    {}\n}}\n",
47
+    ),
48
+    "yash": ShellSyntax(
49
+        "yash",
50
+        alias_fmt="alias {}='{}'\n",
51
+        env_fmt="export {}='{}'\n",
52
+        function_fmt="{}() {{\n    {}\n}}\n",
53
+    ),
54
+    "dash": ShellSyntax(
55
+        "dash",
56
+        alias_fmt="alias {}='{}'\n",
57
+        env_fmt="export {}='{}'\n",
58
+        function_fmt="{}() {{\n    {}\n}}\n",
59
+    ),
60
+    "csh": ShellSyntax(
61
+        "csh",
62
+        alias_fmt="alias {} '{}'\n",
63
+        env_fmt="setenv {} '{}'\n",
64
+        function_fmt="# csh doesn't support functions - skipping {}\n",
65
+    ),
66
+    "tcsh": ShellSyntax(
67
+        "tcsh",
68
+        alias_fmt="alias {} '{}'\n",
69
+        env_fmt="setenv {} '{}'\n",
70
+        function_fmt="# tcsh doesn't support functions - skipping {}\n",
71
+    ),
72
+    "xonsh": ShellSyntax(
73
+        "xonsh",
74
+        alias_fmt="aliases['{}'] = '{}'\n",
75
+        env_fmt="${} = '{}'\n",
76
+        function_fmt="def {}():\n    return '{}'\n",
77
+    ),
78
+    "elvish": ShellSyntax(
79
+        "elvish",
80
+        alias_fmt="fn {} {{ {} }}\n",
81
+        env_fmt="E:{} = '{}'\n",
82
+        function_fmt="fn {} {{ {} }}\n",
83
+    ),
84
+    "rc": ShellSyntax(
85
+        "rc",
86
+        alias_fmt="fn {} {{ {} }}\n",
87
+        env_fmt="{}='{}'\n",
88
+        function_fmt="fn {} {{ {} }}\n",
89
+    ),
90
+    "es": ShellSyntax(
91
+        "es",
92
+        alias_fmt="fn-{} = {{ {} }}\n",
93
+        env_fmt="{}='{}'\n",
94
+        function_fmt="fn-{} = {{ {} }}\n",
95
+    ),
96
+    "nushell": ShellSyntax(
97
+        "nushell",
98
+        alias_fmt="alias {} = {}\n",
99
+        env_fmt="let-env {} = '{}'\n",
100
+        function_fmt="def {} [] {{ {} }}\n",
101
+    ),
102
+    "powershell": ShellSyntax(
103
+        "powershell",
104
+        alias_fmt="Set-Alias -Name {} -Value '{}'\n",
105
+        env_fmt="$env:{} = '{}'\n",
106
+        function_fmt="function {} {{ {} }}\n",
107
+    ),
108
+    "oil": ShellSyntax(
109
+        "oil",
110
+        alias_fmt="alias {}='{}'\n",
111
+        env_fmt="export {}='{}'\n",
112
+        function_fmt="{}() {{\n    {}\n}}\n",
113
+    ),
114
+    "default": ShellSyntax(
115
+        "default",
116
+        alias_fmt="alias {}='{}'\n",
117
+        env_fmt="export {}='{}'\n",
118
+        function_fmt="{}() {{\n    {}\n}}\n",
119
+    ),
120
+}
121
+
122
+
123
+def get_supported_shells():
124
+    """Return list of supported shell names (excluding default)"""
125
+    return [name for name in SHELLS.keys() if name != "default"]
126
+
127
+
128
+def get_shell_syntax(shell_name):
129
+    """Get syntax for a specific shell, falling back to default"""
130
+    return SHELLS.get(shell_name, SHELLS["default"])
src/shtick/shtick.pyadded
@@ -0,0 +1,227 @@
1
+#!/usr/bin/env python3
2
+"""
3
+shtick - Shell alias manager
4
+Generates shell configuration files from TOML config
5
+"""
6
+
7
+import sys
8
+import argparse
9
+import os
10
+from typing import Optional
11
+
12
+# Package imports
13
+from shtick.config import Config
14
+from shtick.generator import Generator
15
+from shtick.shells import get_supported_shells
16
+
17
+
18
+def cmd_generate(args) -> None:
19
+    """Generate shell files from config"""
20
+    config_path = args.config or Config.get_default_config_path()
21
+
22
+    try:
23
+        config = Config(config_path)
24
+        config.load()
25
+
26
+        generator = Generator()
27
+        generator.generate_all(config)
28
+
29
+    except FileNotFoundError as e:
30
+        print(f"Error: {e}")
31
+        print(f"Create a config file at {config_path} first")
32
+        sys.exit(1)
33
+    except Exception as e:
34
+        print(f"Error: {e}")
35
+        sys.exit(1)
36
+
37
+
38
+def cmd_add(args) -> None:
39
+    """Add an item to the config"""
40
+    if "=" not in args.assignment:
41
+        print("Error: Assignment must be in format key=value")
42
+        sys.exit(1)
43
+
44
+    key, value = args.assignment.split("=", 1)
45
+    key = key.strip()
46
+    value = value.strip()
47
+
48
+    if not key or not value:
49
+        print("Error: Both key and value must be non-empty")
50
+        sys.exit(1)
51
+
52
+    config_path = Config.get_default_config_path()
53
+
54
+    try:
55
+        config = Config(config_path)
56
+        # Try to load existing config, create empty if doesn't exist
57
+        try:
58
+            config.load()
59
+        except FileNotFoundError:
60
+            print(f"Creating new config file at {config_path}")
61
+
62
+        config.add_item(args.type, args.group, key, value)
63
+        config.save()
64
+
65
+        print(f"Added {args.type} '{key}' = '{value}' to group '{args.group}'")
66
+
67
+    except Exception as e:
68
+        print(f"Error: {e}")
69
+        sys.exit(1)
70
+
71
+
72
+def cmd_remove(args) -> None:
73
+    """Remove an item from the config"""
74
+    config_path = Config.get_default_config_path()
75
+
76
+    try:
77
+        config = Config(config_path)
78
+        config.load()
79
+
80
+        # Find matching items
81
+        matches = config.find_items(args.type, args.group, args.search)
82
+
83
+        if not matches:
84
+            print(
85
+                f"No {args.type} items matching '{args.search}' found in group '{args.group}'"
86
+            )
87
+            return
88
+
89
+        if len(matches) == 1:
90
+            # Exact match, remove it
91
+            item = matches[0]
92
+            if config.remove_item(args.type, args.group, item):
93
+                config.save()
94
+                print(f"Removed {args.type} '{item}' from group '{args.group}'")
95
+            else:
96
+                print(f"Failed to remove {args.type} '{item}'")
97
+        else:
98
+            # Multiple matches, ask for confirmation
99
+            print(f"Found {len(matches)} matches:")
100
+            for i, item in enumerate(matches, 1):
101
+                print(f"  {i}. {item}")
102
+
103
+            try:
104
+                choice = input("Enter number to remove (or 'q' to quit): ").strip()
105
+                if choice.lower() == "q":
106
+                    print("Cancelled")
107
+                    return
108
+
109
+                idx = int(choice) - 1
110
+                if 0 <= idx < len(matches):
111
+                    item = matches[idx]
112
+                    if config.remove_item(args.type, args.group, item):
113
+                        config.save()
114
+                        print(f"Removed {args.type} '{item}' from group '{args.group}'")
115
+                    else:
116
+                        print(f"Failed to remove {args.type} '{item}'")
117
+                else:
118
+                    print("Invalid choice")
119
+            except (ValueError, KeyboardInterrupt):
120
+                print("\nCancelled")
121
+
122
+    except FileNotFoundError:
123
+        print(f"Config file not found: {config_path}")
124
+        sys.exit(1)
125
+    except Exception as e:
126
+        print(f"Error: {e}")
127
+        sys.exit(1)
128
+
129
+
130
+def cmd_list(args) -> None:
131
+    """List current configuration"""
132
+    config_path = Config.get_default_config_path()
133
+
134
+    try:
135
+        config = Config(config_path)
136
+        config.load()
137
+
138
+        if not config.groups:
139
+            print("No groups configured")
140
+            return
141
+
142
+        for group in config.groups:
143
+            print(f"\nGroup: {group.name}")
144
+
145
+            if group.aliases:
146
+                print(f"  Aliases ({len(group.aliases)}):")
147
+                for key, value in group.aliases.items():
148
+                    print(f"    {key} = {value}")
149
+
150
+            if group.env_vars:
151
+                print(f"  Environment Variables ({len(group.env_vars)}):")
152
+                for key, value in group.env_vars.items():
153
+                    print(f"    {key} = {value}")
154
+
155
+            if group.functions:
156
+                print(f"  Functions ({len(group.functions)}):")
157
+                for key, value in group.functions.items():
158
+                    print(f"    {key} = {value}")
159
+
160
+    except FileNotFoundError:
161
+        print(f"Config file not found: {config_path}")
162
+        print(
163
+            "Use 'shtick add' to create entries or 'shtick generate' with a config file"
164
+        )
165
+    except Exception as e:
166
+        print(f"Error: {e}")
167
+        sys.exit(1)
168
+
169
+
170
+def main():
171
+    """Main CLI entry point"""
172
+    parser = argparse.ArgumentParser(
173
+        description="shtick - Generate shell configuration files from TOML"
174
+    )
175
+
176
+    subparsers = parser.add_subparsers(dest="command", help="Available commands")
177
+
178
+    # Generate command
179
+    gen_parser = subparsers.add_parser(
180
+        "generate", help="Generate shell files from config"
181
+    )
182
+    gen_parser.add_argument("config", nargs="?", help="Path to config TOML file")
183
+
184
+    # Add command
185
+    add_parser = subparsers.add_parser("add", help="Add an item to config")
186
+    add_parser.add_argument(
187
+        "type", choices=["alias", "env", "function"], help="Type of item to add"
188
+    )
189
+    add_parser.add_argument("group", help="Group name")
190
+    add_parser.add_argument("assignment", help="Assignment in format key=value")
191
+
192
+    # Remove command
193
+    rm_parser = subparsers.add_parser("remove", help="Remove an item from config")
194
+    rm_parser.add_argument(
195
+        "type", choices=["alias", "env", "function"], help="Type of item to remove"
196
+    )
197
+    rm_parser.add_argument("group", help="Group name")
198
+    rm_parser.add_argument("search", help="Search term (fuzzy match)")
199
+
200
+    # List command
201
+    list_parser = subparsers.add_parser("list", help="List current configuration")
202
+
203
+    # Shells command (bonus)
204
+    shells_parser = subparsers.add_parser("shells", help="List supported shells")
205
+
206
+    args = parser.parse_args()
207
+
208
+    if not args.command:
209
+        parser.print_help()
210
+        sys.exit(1)
211
+
212
+    if args.command == "generate":
213
+        cmd_generate(args)
214
+    elif args.command == "add":
215
+        cmd_add(args)
216
+    elif args.command == "remove":
217
+        cmd_remove(args)
218
+    elif args.command == "list":
219
+        cmd_list(args)
220
+    elif args.command == "shells":
221
+        print("Supported shells:")
222
+        for shell in sorted(get_supported_shells()):
223
+            print(f"  {shell}")
224
+
225
+
226
+if __name__ == "__main__":
227
+    main()