datasecops-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ @dataclass
6
+ class GitBranchState:
7
+ is_dirty: bool = False
8
+ commits_behind_main: int = 0
9
+ syncs_ahead: int = 0
10
+ syncs_behind: int = 0
11
+ uncommitted_file_count: int = 0
12
+ last_reset: Optional[datetime] = None
13
+ remote_exists: bool = True
14
+
15
+ def should_reset(self) -> bool:
16
+ if self.last_reset is None:
17
+ return True
18
+ return (datetime.now() - self.last_reset).total_seconds() > 60
19
+
20
+ @dataclass
21
+ class GitCommitHelper:
22
+ file: str = ""
23
+ checked: bool = True
24
+
25
+ @dataclass
26
+ class GitBranchComparison:
27
+ local: str = ""
28
+ remote: str = ""
29
+ is_current: bool = False
@@ -0,0 +1,87 @@
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional
3
+
4
+ class DatasecopsConfig(BaseModel):
5
+ """Local .datasecops.yml config."""
6
+ connection_name: str = ""
7
+ app_database: str = ""
8
+ profile_name: str = ""
9
+
10
+ class DbtTarget(BaseModel):
11
+ target_name: str = ""
12
+ branch_name: str = ""
13
+ target_role: str = ""
14
+ target_warehouse: str = ""
15
+ target_schema: str = "PUBLIC"
16
+ is_default: bool = False
17
+
18
+ class ProjectSettings(BaseModel):
19
+ project_dir: str = "./dbt"
20
+ profile_dir: str = "~/.dbt"
21
+ execution_mode: str = "dbt_cli"
22
+ targets: list[DbtTarget] = Field(default_factory=lambda: [
23
+ DbtTarget(target_name="dev", branch_name="dev", target_role="DEVELOPERS", target_warehouse="DEV_WH", is_default=True),
24
+ DbtTarget(target_name="test", branch_name="test", target_role="DATAOPS_ADMIN", target_warehouse="DATAOPS_WH"),
25
+ DbtTarget(target_name="prod", branch_name="prod", target_role="DATAOPS_ADMIN", target_warehouse="DATAOPS_WH"),
26
+ ])
27
+ sources_database_prefix: list[str] = Field(default_factory=list)
28
+
29
+ def get_default_target(self) -> Optional[DbtTarget]:
30
+ for t in self.targets:
31
+ if t.is_default:
32
+ return t
33
+ return self.targets[0] if self.targets else None
34
+
35
+ def get_deployment_branches(self) -> list[str]:
36
+ return [t.branch_name for t in self.targets if t.branch_name]
37
+
38
+ class BranchType(BaseModel):
39
+ name: str = ""
40
+ purpose: str = ""
41
+ lifecycle: str = ""
42
+ use_case: str = ""
43
+
44
+ class EnvironmentBranch(BaseModel):
45
+ branch_name: str = ""
46
+ purpose: str = ""
47
+ merge_into_using: str = ""
48
+ auto_released_to_environment: str = ""
49
+
50
+ class SourceControl(BaseModel):
51
+ branch_types: list[BranchType] = Field(default_factory=list)
52
+ ticket_number_required: bool = False
53
+ branch_format: str = "{branch_type}/{branch_name}"
54
+ source_control_platform: str = "GitHub"
55
+ environments: list[EnvironmentBranch] = Field(default_factory=list)
56
+
57
+ class ProjectProfile(BaseModel):
58
+ project_id: int = 0
59
+ profile_name: str = ""
60
+ project_name: str = ""
61
+ project_description: str = ""
62
+ model_types: list[str] = Field(default_factory=list)
63
+ downstream_projects: list[str] = Field(default_factory=list)
64
+ git_url: str = ""
65
+ target_database: str = ""
66
+ dbt_version: str = "1.9"
67
+ dbt_packages: list[str] = Field(default_factory=list)
68
+
69
+ class DbtPackage(BaseModel):
70
+ name: str = ""
71
+ source: str = "git"
72
+ url: str = ""
73
+ description: str = ""
74
+ latest_version: str = ""
75
+
76
+ class CortexSkillFile(BaseModel):
77
+ filename: str = ""
78
+ content: str = ""
79
+
80
+ class CortexSkill(BaseModel):
81
+ skill_id: str = ""
82
+ name: str = ""
83
+ description: str = ""
84
+ version: str = "0.1.0"
85
+ category: str = "general"
86
+ files: list[CortexSkillFile] = Field(default_factory=list)
87
+ enabled: bool = True
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,130 @@
1
+ import subprocess
2
+ import shutil
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from datasecops_cli.utilities.display import info_line, error_line, success_line
7
+
8
+
9
+ class DbtRunner:
10
+ """Runs dbt commands via subprocess (dbt Fusion)."""
11
+
12
+ def __init__(self, project_dir: Path, profiles_dir: Path, target: str = "dev"):
13
+ self.project_dir = project_dir
14
+ self.profiles_dir = profiles_dir
15
+ self.target = target
16
+
17
+ def _default_args(self) -> list[str]:
18
+ return [
19
+ f"--project-dir={self.project_dir}",
20
+ f"--profiles-dir={self.profiles_dir}",
21
+ ]
22
+
23
+ def _run_command(self, command: str, extra_args: list[str] = None) -> subprocess.CompletedProcess:
24
+ cmd = ["dbt", command] + (extra_args or []) + self._default_args()
25
+ info_line(f"Running: {' '.join(cmd)}")
26
+ result = subprocess.run(cmd, capture_output=False)
27
+ if result.returncode != 0:
28
+ error_line(f"dbt {command} failed with exit code {result.returncode}")
29
+ else:
30
+ success_line(f"dbt {command} completed successfully")
31
+ return result
32
+
33
+ def run(self, select: str = None, exclude: str = None, full_refresh: bool = False,
34
+ modified_only: bool = False) -> subprocess.CompletedProcess:
35
+ args = [f"--target={self.target}"]
36
+ if modified_only and (self.project_dir / "manifest.json").exists():
37
+ args += ["--select", "state:modified+", "--state", str(self.project_dir)]
38
+ elif select:
39
+ args += ["--select", select]
40
+ if exclude:
41
+ args += ["--exclude", exclude]
42
+ if full_refresh:
43
+ args.append("--full-refresh")
44
+ result = self._run_command("run", args)
45
+ self._copy_manifest()
46
+ return result
47
+
48
+ def build(self, select: str = None, exclude: str = None) -> subprocess.CompletedProcess:
49
+ args = [f"--target={self.target}"]
50
+ if select:
51
+ args += ["--select", select]
52
+ if exclude:
53
+ args += ["--exclude", exclude]
54
+ result = self._run_command("build", args)
55
+ self._copy_manifest()
56
+ return result
57
+
58
+ def test(self, select: str = None) -> subprocess.CompletedProcess:
59
+ args = [f"--target={self.target}"]
60
+ if select:
61
+ args += ["--select", select]
62
+ return self._run_command("test", args)
63
+
64
+ def compile(self, select: str = None) -> subprocess.CompletedProcess:
65
+ args = [f"--target={self.target}"]
66
+ if select:
67
+ args += ["--select", select]
68
+ return self._run_command("compile", args)
69
+
70
+ def deps(self) -> subprocess.CompletedProcess:
71
+ return self._run_command("deps")
72
+
73
+ def seed(self, select: str = None, full_refresh: bool = False) -> subprocess.CompletedProcess:
74
+ args = [f"--target={self.target}"]
75
+ if select:
76
+ args += ["--select", select]
77
+ if full_refresh:
78
+ args.append("--full-refresh")
79
+ return self._run_command("seed", args)
80
+
81
+ def snapshot(self, select: str = None) -> subprocess.CompletedProcess:
82
+ args = [f"--target={self.target}"]
83
+ if select:
84
+ args += ["--select", select]
85
+ return self._run_command("snapshot", args)
86
+
87
+ def source_freshness(self, select: str = None) -> subprocess.CompletedProcess:
88
+ args = [f"--target={self.target}"]
89
+ if select:
90
+ args += ["--select", select]
91
+ return self._run_command("source", ["freshness"] + args)
92
+
93
+ def docs_generate(self) -> subprocess.CompletedProcess:
94
+ return self._run_command("docs", ["generate", f"--target={self.target}"])
95
+
96
+ def docs_serve(self) -> subprocess.Popen:
97
+ cmd = ["dbt", "docs", "serve"] + self._default_args()
98
+ info_line(f"Running: {' '.join(cmd)}")
99
+ return subprocess.Popen(cmd)
100
+
101
+ def clean(self) -> subprocess.CompletedProcess:
102
+ return self._run_command("clean")
103
+
104
+ def debug(self) -> subprocess.CompletedProcess:
105
+ return self._run_command("debug", [f"--target={self.target}"])
106
+
107
+ def list_models(self, select: str = None, resource_type: str = None) -> subprocess.CompletedProcess:
108
+ args = [f"--target={self.target}"]
109
+ if select:
110
+ args += ["--select", select]
111
+ if resource_type:
112
+ args += ["--resource-type", resource_type]
113
+ return self._run_command("list", args)
114
+
115
+ def retry(self) -> subprocess.CompletedProcess:
116
+ result = self._run_command("retry", [f"--target={self.target}"])
117
+ self._copy_manifest()
118
+ return result
119
+
120
+ def run_operation(self, macro: str, args_str: str = None) -> subprocess.CompletedProcess:
121
+ cmd_args = [f"--target={self.target}"]
122
+ if args_str:
123
+ cmd_args += ["--args", args_str]
124
+ return self._run_command("run-operation", [macro] + cmd_args)
125
+
126
+ def _copy_manifest(self) -> None:
127
+ manifest = self.project_dir / "target" / "manifest.json"
128
+ dest = self.project_dir / "manifest.json"
129
+ if manifest.exists():
130
+ shutil.copy2(manifest, dest)
@@ -0,0 +1,103 @@
1
+ import json
2
+ import yaml
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from datasecops_cli.services.snowflake_service import SnowflakeService
7
+ from datasecops_cli.utilities.display import info_line, success_line, error_line
8
+ from datasecops_cli.utilities.file_utils import write_file, ensure_dir
9
+
10
+
11
+ class DownloadService:
12
+ """Downloads configurations from the native app."""
13
+
14
+ def __init__(self, snowflake_service: SnowflakeService, project_dir: Path):
15
+ self.sf = snowflake_service
16
+ self.project_dir = project_dir
17
+
18
+ def download_sqlfluff_config(self) -> bool:
19
+ info_line("Downloading SQLFluff configuration...")
20
+ raw = self.sf.get_framework_config("SQLFLUFF_RULES")
21
+ if not raw:
22
+ error_line("No SQLFluff configuration found in native app")
23
+ return False
24
+
25
+ lines = ["[sqlfluff]", "dialect = snowflake", "templater = dbt", ""]
26
+
27
+ core = raw.get("core", {})
28
+ for code, state in core.items():
29
+ if isinstance(state, dict) and state.get("enabled", True):
30
+ opts = state.get("options", {})
31
+ for opt_key, opt_val in opts.items():
32
+ lines.append(f"{opt_key} = {opt_val}")
33
+
34
+ lines.append("")
35
+ lines.append("[sqlfluff:indentation]")
36
+ indentation = raw.get("indentation", {})
37
+ for code, state in indentation.items():
38
+ if isinstance(state, dict):
39
+ opts = state.get("options", {})
40
+ for opt_key, opt_val in opts.items():
41
+ lines.append(f"{opt_key} = {opt_val}")
42
+
43
+ content = "\n".join(lines) + "\n"
44
+ dest = self.project_dir / ".sqlfluff"
45
+ write_file(dest, content)
46
+ success_line(f"SQLFluff config written to {dest}")
47
+ return True
48
+
49
+ def download_pipelines(self, platform: str = "github") -> bool:
50
+ info_line(f"Downloading {platform} pipeline configurations...")
51
+ raw = self.sf.get_framework_config("PIPELINES")
52
+ if not raw:
53
+ error_line("No pipeline configuration found in native app")
54
+ return False
55
+
56
+ pipelines = raw.get("pipelines", [])
57
+ count = 0
58
+ for pipe in pipelines:
59
+ if pipe.get("platform", "github") != platform or not pipe.get("enabled", True):
60
+ continue
61
+ filename = pipe.get("filename", "")
62
+ yaml_content = pipe.get("yaml_content", "")
63
+ if filename and yaml_content:
64
+ if platform == "github":
65
+ dest = self.project_dir / ".github" / "workflows" / filename
66
+ else:
67
+ dest = self.project_dir / filename
68
+ write_file(dest, yaml_content)
69
+ info_line(f" Written: {dest}")
70
+ count += 1
71
+
72
+ success_line(f"Downloaded {count} pipeline file(s)")
73
+ return True
74
+
75
+ def download_dbt_packages(self, dbt_project_dir: Path) -> bool:
76
+ info_line("Downloading dbt package versions...")
77
+ raw = self.sf.get_framework_config("DBT_PACKAGES")
78
+ if not raw:
79
+ error_line("No dbt packages configuration found in native app")
80
+ return False
81
+
82
+ packages = raw.get("packages", [])
83
+ pkg_list = []
84
+ for pkg in packages:
85
+ source = pkg.get("source", "git")
86
+ url = pkg.get("url", "")
87
+ version = pkg.get("latest_version", "")
88
+ if source == "git" and url:
89
+ entry = {"git": url}
90
+ if version:
91
+ entry["revision"] = version
92
+ pkg_list.append(entry)
93
+ elif source == "package" and url:
94
+ entry = {"package": url}
95
+ if version:
96
+ entry["version"] = version
97
+ pkg_list.append(entry)
98
+
99
+ dest = dbt_project_dir / "packages.yml"
100
+ content = yaml.dump({"packages": pkg_list}, default_flow_style=False, sort_keys=False)
101
+ write_file(dest, content)
102
+ success_line(f"packages.yml written to {dest}")
103
+ return True
@@ -0,0 +1,183 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+ from git import Repo, GitCommandError
4
+
5
+ from datasecops_cli.models.git_helpers import GitCommitHelper
6
+ from datasecops_cli.utilities.display import info_line, error_line, success_line, warning_line
7
+
8
+
9
+ class GitService:
10
+ """Git operations via GitPython."""
11
+
12
+ def __init__(self, repo_path: Path):
13
+ self.repo = Repo(repo_path)
14
+
15
+ def get_current_branch(self) -> str:
16
+ try:
17
+ return self.repo.active_branch.name
18
+ except TypeError:
19
+ return "DETACHED HEAD"
20
+
21
+ def is_dirty(self) -> bool:
22
+ return self.repo.is_dirty(untracked_files=True)
23
+
24
+ def get_uncommitted_file_count(self) -> int:
25
+ return len(self.repo.index.diff(None)) + len(self.repo.untracked_files)
26
+
27
+ def get_changed_files(self) -> list[GitCommitHelper]:
28
+ return [GitCommitHelper(file=d.a_path) for d in self.repo.index.diff(None)]
29
+
30
+ def get_new_files(self) -> list[GitCommitHelper]:
31
+ return [GitCommitHelper(file=f) for f in self.repo.untracked_files]
32
+
33
+ def get_local_branches(self) -> dict[str, str]:
34
+ return {b.name: b.name for b in self.repo.branches}
35
+
36
+ def get_remote_branches(self) -> dict[str, str]:
37
+ self.repo.remotes.origin.fetch()
38
+ return {
39
+ ref.remote_head: ref.remote_head
40
+ for ref in self.repo.remotes.origin.refs
41
+ if ref.remote_head != "HEAD"
42
+ }
43
+
44
+ def create_branch(self, branch_type: str, ticket: str, name: str) -> None:
45
+ branch_name = f"{branch_type}/{ticket}_{name}" if ticket else f"{branch_type}/{name}"
46
+ info_line(f"Creating branch: {branch_name}")
47
+ self.repo.remotes.origin.fetch()
48
+ new_branch = self.repo.create_head(branch_name, "origin/main")
49
+ new_branch.checkout()
50
+ self.repo.remotes.origin.push(branch_name, set_upstream=True)
51
+ success_line(f"Branch {branch_name} created and checked out")
52
+
53
+ def checkout_branch(self, remote_name: str) -> None:
54
+ info_line(f"Checking out: {remote_name}")
55
+ self.repo.remotes.origin.fetch()
56
+ try:
57
+ self.repo.git.checkout(remote_name)
58
+ except GitCommandError:
59
+ self.repo.git.checkout("-b", remote_name, f"origin/{remote_name}")
60
+ success_line(f"Switched to {remote_name}")
61
+
62
+ def switch_branch(self, name: str) -> None:
63
+ self.repo.branches[name].checkout()
64
+ success_line(f"Switched to {name}")
65
+
66
+ def delete_branch(self, name: str) -> None:
67
+ if name == self.get_current_branch():
68
+ error_line("Cannot delete the current branch")
69
+ return
70
+ self.repo.delete_head(name, force=True)
71
+ try:
72
+ self.repo.remotes.origin.push(refspec=f":{name}")
73
+ except GitCommandError:
74
+ pass
75
+ success_line(f"Deleted branch {name}")
76
+
77
+ def commit_changes(self, message: str) -> None:
78
+ self.repo.git.add(A=True)
79
+ self.repo.index.commit(message)
80
+ success_line(f"Committed: {message}")
81
+
82
+ def push_branch(self) -> None:
83
+ branch = self.get_current_branch()
84
+ info_line(f"Pushing {branch}...")
85
+ self.repo.remotes.origin.push(branch)
86
+ success_line(f"Pushed {branch}")
87
+
88
+ def pull_branch(self) -> None:
89
+ branch = self.get_current_branch()
90
+ info_line(f"Pulling {branch}...")
91
+ self.repo.remotes.origin.pull(branch)
92
+ success_line(f"Pulled {branch}")
93
+
94
+ def push_branch_to_destination(self, destination: str, force: bool = False) -> None:
95
+ current = self.get_current_branch()
96
+ info_line(f"Pushing {current} to {destination}...")
97
+ refspec = f"{current}:{destination}"
98
+ self.repo.remotes.origin.push(refspec=refspec, force=force)
99
+ success_line(f"Pushed to {destination}")
100
+
101
+ def rebase_with_main(self) -> bool:
102
+ try:
103
+ self.repo.remotes.origin.fetch()
104
+ self.repo.git.rebase("origin/main")
105
+ success_line("Rebase with main completed")
106
+ return True
107
+ except GitCommandError as e:
108
+ error_line(f"Rebase conflict: {e}")
109
+ warning_line("Resolve conflicts then use 'rebase continue' or 'rebase abort'")
110
+ return False
111
+
112
+ def rebase_continue(self) -> None:
113
+ self.repo.git.rebase("--continue")
114
+ success_line("Rebase continued")
115
+
116
+ def rebase_abort(self) -> None:
117
+ self.repo.git.rebase("--abort")
118
+ success_line("Rebase aborted")
119
+
120
+ def squash_and_rebase(self) -> bool:
121
+ try:
122
+ self.repo.remotes.origin.fetch()
123
+ main_commit = self.repo.commit("origin/main")
124
+ current = self.get_current_branch()
125
+ self.repo.git.reset("--soft", main_commit.hexsha)
126
+ self.repo.index.commit(f"squash: {current}")
127
+ self.repo.git.rebase("origin/main")
128
+ success_line("Squash and rebase completed")
129
+ return True
130
+ except GitCommandError as e:
131
+ error_line(f"Squash rebase failed: {e}")
132
+ return False
133
+
134
+ def squash_merge_into_test(self) -> bool:
135
+ current = self.get_current_branch()
136
+ try:
137
+ self.repo.remotes.origin.fetch()
138
+ self.repo.git.checkout("test")
139
+ self.repo.remotes.origin.pull("test")
140
+ self.repo.git.merge("--squash", current)
141
+ self.repo.index.commit(f"squash merge: {current} into test")
142
+ self.repo.remotes.origin.push("test")
143
+ self.repo.git.checkout(current)
144
+ success_line(f"Squash merged {current} into test")
145
+ return True
146
+ except GitCommandError as e:
147
+ error_line(f"Squash merge failed: {e}")
148
+ try:
149
+ self.repo.git.checkout(current)
150
+ except Exception:
151
+ pass
152
+ return False
153
+
154
+ def cherry_pick_from_test(self, commit_sha: str) -> bool:
155
+ try:
156
+ self.repo.git.cherry_pick(commit_sha)
157
+ success_line(f"Cherry-picked {commit_sha[:8]}")
158
+ return True
159
+ except GitCommandError as e:
160
+ error_line(f"Cherry-pick failed: {e}")
161
+ return False
162
+
163
+ def get_test_commits(self, limit: int = 20) -> list[dict]:
164
+ try:
165
+ self.repo.remotes.origin.fetch()
166
+ commits = list(self.repo.iter_commits("origin/test", max_count=limit))
167
+ return [{"sha": c.hexsha, "message": c.message.strip(), "author": str(c.author), "date": str(c.committed_datetime)} for c in commits]
168
+ except Exception:
169
+ return []
170
+
171
+ def prune_remote_branches(self) -> None:
172
+ self.repo.remotes.origin.fetch(prune=True)
173
+ success_line("Pruned remote branches")
174
+
175
+ def reset_to_main(self) -> None:
176
+ self.repo.remotes.origin.fetch()
177
+ self.repo.git.checkout("main")
178
+ self.repo.git.reset("--hard", "origin/main")
179
+ # Delete all local branches except main
180
+ for branch in self.repo.branches:
181
+ if branch.name != "main":
182
+ self.repo.delete_head(branch, force=True)
183
+ success_line("Reset to main completed")
@@ -0,0 +1,47 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from datasecops_cli.utilities.display import info_line, error_line, success_line
5
+
6
+
7
+ class LintingService:
8
+ """SQLFluff linting operations."""
9
+
10
+ def __init__(self, project_dir: Path):
11
+ self.project_dir = project_dir
12
+
13
+ def lint_file(self, file_path: str = None, fix: bool = False) -> subprocess.CompletedProcess:
14
+ action = "fix" if fix else "lint"
15
+ cmd = ["sqlfluff", action]
16
+ if file_path:
17
+ cmd.append(file_path)
18
+ else:
19
+ cmd.append(str(self.project_dir / "models"))
20
+
21
+ config_path = self.project_dir / ".sqlfluff"
22
+ if config_path.exists():
23
+ cmd += ["--config", str(config_path)]
24
+
25
+ info_line(f"Running: sqlfluff {action}")
26
+ result = subprocess.run(cmd, capture_output=False, cwd=str(self.project_dir))
27
+ if result.returncode == 0:
28
+ success_line(f"SQLFluff {action} completed — no issues found")
29
+ elif result.returncode == 1 and not fix:
30
+ info_line(f"SQLFluff {action} found linting issues")
31
+ else:
32
+ error_line(f"SQLFluff {action} failed with exit code {result.returncode}")
33
+ return result
34
+
35
+ def lint_modified(self, fix: bool = False, changed_files: list[str] = None) -> None:
36
+ if not changed_files:
37
+ info_line("No modified SQL files to lint")
38
+ return
39
+
40
+ sql_files = [f for f in changed_files if f.endswith(".sql")]
41
+ if not sql_files:
42
+ info_line("No modified SQL files to lint")
43
+ return
44
+
45
+ info_line(f"Linting {len(sql_files)} modified SQL file(s)...")
46
+ for f in sql_files:
47
+ self.lint_file(f, fix=fix)
@@ -0,0 +1,86 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from datasecops_cli.services.snowflake_service import SnowflakeService
5
+ from datasecops_cli.models.project_config import CortexSkill, CortexSkillFile
6
+ from datasecops_cli.utilities.display import info_line, success_line, error_line, warning_line
7
+ from datasecops_cli.utilities.file_utils import write_file, ensure_dir, get_cortex_skills_dir
8
+
9
+
10
+ class SkillService:
11
+ """Manages Cortex Code skill installation."""
12
+
13
+ def __init__(self, snowflake_service: SnowflakeService):
14
+ self.sf = snowflake_service
15
+
16
+ def get_available_skills(self) -> list[CortexSkill]:
17
+ raw = self.sf.get_framework_config("CORTEX_SKILLS")
18
+ if not raw:
19
+ return []
20
+ skills_data = raw.get("skills", [])
21
+ result = []
22
+ for s in skills_data:
23
+ if isinstance(s, dict):
24
+ files = [CortexSkillFile(**f) for f in s.get("files", []) if isinstance(f, dict)]
25
+ skill = CortexSkill(
26
+ skill_id=s.get("skill_id", ""),
27
+ name=s.get("name", ""),
28
+ description=s.get("description", ""),
29
+ version=s.get("version", "0.1.0"),
30
+ category=s.get("category", "general"),
31
+ files=files,
32
+ enabled=s.get("enabled", True),
33
+ )
34
+ if skill.enabled:
35
+ result.append(skill)
36
+ return result
37
+
38
+ def list_skills(self) -> None:
39
+ skills = self.get_available_skills()
40
+ if not skills:
41
+ info_line("No skills available in the native app")
42
+ return
43
+
44
+ info_line(f"Available Cortex Code skills ({len(skills)}):")
45
+ for s in skills:
46
+ installed = self._is_installed(s.skill_id)
47
+ status = "installed" if installed else "not installed"
48
+ info_line(f" [{s.category}] {s.name} v{s.version} — {status}")
49
+ if s.description:
50
+ info_line(f" {s.description}")
51
+
52
+ def install_skill(self, skill: CortexSkill, target_dir: Path = None) -> bool:
53
+ target = target_dir or get_cortex_skills_dir()
54
+ skill_dir = target / skill.skill_id
55
+ ensure_dir(skill_dir)
56
+
57
+ for f in skill.files:
58
+ if f.filename and f.content:
59
+ write_file(skill_dir / f.filename, f.content)
60
+
61
+ success_line(f"Installed skill: {skill.name} v{skill.version} -> {skill_dir}")
62
+ return True
63
+
64
+ def install_all(self, target_dir: Path = None) -> int:
65
+ skills = self.get_available_skills()
66
+ count = 0
67
+ for skill in skills:
68
+ if self.install_skill(skill, target_dir):
69
+ count += 1
70
+ success_line(f"Installed {count} skill(s)")
71
+ return count
72
+
73
+ def update_skills(self, target_dir: Path = None) -> int:
74
+ skills = self.get_available_skills()
75
+ target = target_dir or get_cortex_skills_dir()
76
+ count = 0
77
+ for skill in skills:
78
+ if self._is_installed(skill.skill_id, target):
79
+ self.install_skill(skill, target)
80
+ count += 1
81
+ success_line(f"Updated {count} skill(s)")
82
+ return count
83
+
84
+ def _is_installed(self, skill_id: str, target_dir: Path = None) -> bool:
85
+ target = target_dir or get_cortex_skills_dir()
86
+ return (target / skill_id).exists()