nod-cli 0.1.0rc1__tar.gz

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,22 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ pypi:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.13"
20
+ - run: python -m pip install --upgrade build
21
+ - run: python -m build
22
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .nod/
6
+ .DS_Store
7
+ dist/
8
+ build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neatnettech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: nod-cli
3
+ Version: 0.1.0rc1
4
+ Summary: Git-native project management for humans and coding agents.
5
+ Project-URL: Homepage, https://github.com/neatnettech/nod
6
+ Project-URL: Repository, https://github.com/neatnettech/nod
7
+ Author: neatnettech
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,git,kanban,mcp,project-management
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS :: MacOS X
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development
19
+ Requires-Python: >=3.13
20
+ Requires-Dist: alembic<2,>=1.16
21
+ Requires-Dist: mcp<2,>=1.9
22
+ Requires-Dist: networkx<4,>=3.4
23
+ Requires-Dist: pydantic<3,>=2.11
24
+ Requires-Dist: rich<15,>=14
25
+ Requires-Dist: sqlalchemy<3,>=2.0
26
+ Requires-Dist: typer<1,>=0.16
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest-asyncio<2,>=1; extra == 'dev'
29
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Nod
33
+
34
+ Git-native project management for humans and coding agents.
35
+
36
+ Nod lives in your repository: project state is stored in `.nod/nod.db`, so
37
+ the plan travels with the code. Humans drive it through a focused CLI,
38
+ coding agents drive the same state through an MCP server.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ # released version from PyPI (both nod and nod-mcp on PATH)
44
+ pipx install nod-cli
45
+
46
+ # bleeding edge, straight from main
47
+ pipx install git+https://github.com/neatnettech/nod.git
48
+ ```
49
+
50
+ After a PyPI release, update with `pipx upgrade nod-cli`. For git installs,
51
+ run `pipx reinstall nod-cli` to pick up new commits.
52
+
53
+ ## Quick start
54
+
55
+ ```bash
56
+ python -m venv .venv
57
+ source .venv/bin/activate
58
+ pip install -e ".[dev]"
59
+
60
+ mkdir demo && cd demo
61
+ git init
62
+ nod init
63
+ ```
64
+
65
+ ## Concepts
66
+
67
+ * **Modules** group work by area: `nod module add "Vault"`
68
+ * **Cycles** are timeboxed plans: `nod cycle add "Sprint 1" --start 2026-10-01 --end 2026-10-14`
69
+ * **Work items** are epics, stories, tasks, and bugs:
70
+ `nod task add "Seed categories" --module vault --cycle sprint-1 --estimate 2h`
71
+ * **Dependencies** order the work: `nod depends OSS-2 OSS-1`
72
+
73
+ ## Views
74
+
75
+ ```bash
76
+ nod task list # tabular work items
77
+ nod module list # modules
78
+ nod cycle list # cycles
79
+ nod board # kanban grouped by status
80
+ nod timeline # cycles over time with estimates
81
+ nod graph # ASCII dependency diagram
82
+ ```
83
+
84
+ The dependency graph renders as a tree of prerequisite arrows with status
85
+ glyphs and branch names:
86
+
87
+ ```
88
+ OSS-1 StateBadge component ✓ DONE ⎇ feat/oss-1-statebadge
89
+ OSS-3 Seed categories ○ TODO
90
+ ├─► OSS-4 Vault home ◐ IN_PROGRESS (depends on this)
91
+ └─► OSS-6 Note editor ○ TODO (depends on this)
92
+ ```
93
+
94
+ Every view accepts `--json` for scripting and agents.
95
+
96
+ ## Agents
97
+
98
+ `nod-mcp` exposes the same state as MCP tools: work item create/update/list,
99
+ project info, the board, the timeline, and the dependency graph. Point your
100
+ MCP client at `nod-mcp` from inside the repository.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ pip install -e ".[dev]"
106
+ pytest
107
+ ```
108
+
109
+ Releases: tag a version (`v0.1.0-rc.1`, then `v0.1.0`) and push it. The
110
+ `publish` workflow builds the version from the tag and publishes to PyPI via
111
+ trusted publishing. Requires Python 3.13+.
@@ -0,0 +1,80 @@
1
+ # Nod
2
+
3
+ Git-native project management for humans and coding agents.
4
+
5
+ Nod lives in your repository: project state is stored in `.nod/nod.db`, so
6
+ the plan travels with the code. Humans drive it through a focused CLI,
7
+ coding agents drive the same state through an MCP server.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ # released version from PyPI (both nod and nod-mcp on PATH)
13
+ pipx install nod-cli
14
+
15
+ # bleeding edge, straight from main
16
+ pipx install git+https://github.com/neatnettech/nod.git
17
+ ```
18
+
19
+ After a PyPI release, update with `pipx upgrade nod-cli`. For git installs,
20
+ run `pipx reinstall nod-cli` to pick up new commits.
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ python -m venv .venv
26
+ source .venv/bin/activate
27
+ pip install -e ".[dev]"
28
+
29
+ mkdir demo && cd demo
30
+ git init
31
+ nod init
32
+ ```
33
+
34
+ ## Concepts
35
+
36
+ * **Modules** group work by area: `nod module add "Vault"`
37
+ * **Cycles** are timeboxed plans: `nod cycle add "Sprint 1" --start 2026-10-01 --end 2026-10-14`
38
+ * **Work items** are epics, stories, tasks, and bugs:
39
+ `nod task add "Seed categories" --module vault --cycle sprint-1 --estimate 2h`
40
+ * **Dependencies** order the work: `nod depends OSS-2 OSS-1`
41
+
42
+ ## Views
43
+
44
+ ```bash
45
+ nod task list # tabular work items
46
+ nod module list # modules
47
+ nod cycle list # cycles
48
+ nod board # kanban grouped by status
49
+ nod timeline # cycles over time with estimates
50
+ nod graph # ASCII dependency diagram
51
+ ```
52
+
53
+ The dependency graph renders as a tree of prerequisite arrows with status
54
+ glyphs and branch names:
55
+
56
+ ```
57
+ OSS-1 StateBadge component ✓ DONE ⎇ feat/oss-1-statebadge
58
+ OSS-3 Seed categories ○ TODO
59
+ ├─► OSS-4 Vault home ◐ IN_PROGRESS (depends on this)
60
+ └─► OSS-6 Note editor ○ TODO (depends on this)
61
+ ```
62
+
63
+ Every view accepts `--json` for scripting and agents.
64
+
65
+ ## Agents
66
+
67
+ `nod-mcp` exposes the same state as MCP tools: work item create/update/list,
68
+ project info, the board, the timeline, and the dependency graph. Point your
69
+ MCP client at `nod-mcp` from inside the repository.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ pip install -e ".[dev]"
75
+ pytest
76
+ ```
77
+
78
+ Releases: tag a version (`v0.1.0-rc.1`, then `v0.1.0`) and push it. The
79
+ `publish` workflow builds the version from the tag and publishes to PyPI via
80
+ trusted publishing. Requires Python 3.13+.
@@ -0,0 +1,3 @@
1
+ # Alembic migrations
2
+
3
+ The initial scaffold uses SQLAlchemy metadata for bootstrap. Alembic migrations should be introduced before production use.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "nod-cli"
3
+ dynamic = ["version"]
4
+ description = "Git-native project management for humans and coding agents."
5
+ requires-python = ">=3.13"
6
+ license = { text = "MIT" }
7
+ readme = "README.md"
8
+ authors = [{ name = "neatnettech" }]
9
+ keywords = ["project-management", "cli", "kanban", "mcp", "git"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: MacOS :: MacOS X",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development",
19
+ ]
20
+ dependencies = [
21
+ "alembic>=1.16,<2",
22
+ "mcp>=1.9,<2",
23
+ "networkx>=3.4,<4",
24
+ "pydantic>=2.11,<3",
25
+ "rich>=14,<15",
26
+ "sqlalchemy>=2.0,<3",
27
+ "typer>=0.16,<1",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/neatnettech/nod"
32
+ Repository = "https://github.com/neatnettech/nod"
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8,<9",
37
+ "pytest-asyncio>=1,<2",
38
+ ]
39
+
40
+ [project.scripts]
41
+ nod = "nod.cli.app:app"
42
+ nod-mcp = "nod.mcp.server:main"
43
+
44
+ [build-system]
45
+ requires = ["hatchling", "hatch-vcs"]
46
+ build-backend = "hatchling.build"
47
+
48
+ [tool.hatch.version]
49
+ source = "vcs"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/nod"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ asyncio_mode = "auto"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,195 @@
1
+ from pathlib import Path
2
+ import re
3
+ from uuid import UUID, uuid4
4
+
5
+ import networkx as nx
6
+ from sqlalchemy import select
7
+
8
+ from nod.domain.enums import (
9
+ CycleStatus,
10
+ ModuleStatus,
11
+ Priority,
12
+ RelationType,
13
+ WorkItemStatus,
14
+ WorkItemType,
15
+ )
16
+ from nod.domain.errors import DependencyCycleError, NotFoundError, ValidationError
17
+ from nod.domain.models import Cycle, Module, WorkItem
18
+ from nod.infrastructure.models import WorkItemRelationModel
19
+
20
+
21
+ def slugify(value: str) -> str:
22
+ value = value.strip().lower()
23
+ value = re.sub(r"[^a-z0-9]+", "-", value)
24
+ return value.strip("-")
25
+
26
+
27
+ class Services:
28
+ def __init__(self, session, project):
29
+ from nod.infrastructure.repositories import (
30
+ CycleRepository,
31
+ ModuleRepository,
32
+ WorkItemRepository,
33
+ )
34
+ self.session = session
35
+ self.project = project
36
+ self.modules = ModuleRepository(session)
37
+ self.cycles = CycleRepository(session)
38
+ self.items = WorkItemRepository(session)
39
+
40
+ def create_module(self, name: str, description=None) -> Module:
41
+ if not name.strip():
42
+ raise ValidationError("Module name cannot be empty.")
43
+ module = Module(uuid4(), self.project.id, name.strip(), slugify(name), description=description)
44
+ self.modules.add(module)
45
+ self.session.commit()
46
+ return module
47
+
48
+ def update_module(self, ref: str, title=None, description=None, append=None, status=None, start=None, target=None):
49
+ module = self.modules.get(ref)
50
+ if not module:
51
+ raise NotFoundError(f"Module not found: {ref}")
52
+ if title is not None:
53
+ module.name = title.strip()
54
+ if description is not None:
55
+ module.description = description
56
+ if append is not None:
57
+ module.description = ((module.description or "").rstrip() + "\n\n" + append.strip()).strip()
58
+ if status is not None:
59
+ module.status = ModuleStatus(status)
60
+ self.modules.update(module)
61
+ self.session.commit()
62
+ return module
63
+
64
+ def create_cycle(self, name: str, start=None, end=None, description=None) -> Cycle:
65
+ if not name.strip():
66
+ raise ValidationError("Cycle name cannot be empty.")
67
+ if start and end and start > end:
68
+ raise ValidationError("Cycle start date cannot be after end date.")
69
+ cycle = Cycle(uuid4(), self.project.id, name.strip(), slugify(name), description=description, start_date=start, end_date=end)
70
+ self.cycles.add(cycle)
71
+ self.session.commit()
72
+ return cycle
73
+
74
+ def update_cycle(self, ref: str, title=None, description=None, append=None, status=None, start=None, end=None):
75
+ cycle = self.cycles.get(ref)
76
+ if not cycle:
77
+ raise NotFoundError(f"Cycle not found: {ref}")
78
+ new_start = start if start is not None else cycle.start_date
79
+ new_end = end if end is not None else cycle.end_date
80
+ if new_start and new_end and new_start > new_end:
81
+ raise ValidationError("Cycle start date cannot be after end date.")
82
+ if title is not None:
83
+ cycle.name = title.strip()
84
+ if description is not None:
85
+ cycle.description = description
86
+ if append is not None:
87
+ cycle.description = ((cycle.description or "").rstrip() + "\n\n" + append.strip()).strip()
88
+ if status is not None:
89
+ cycle.status = CycleStatus(status)
90
+ cycle.start_date, cycle.end_date = new_start, new_end
91
+ self.cycles.update(cycle)
92
+ self.session.commit()
93
+ return cycle
94
+
95
+ def create_task(self, title: str, description=None, type_=WorkItemType.TASK, priority=Priority.MEDIUM, module=None, cycle=None, estimate=None, status=None, branch=None):
96
+ if not title.strip():
97
+ raise ValidationError("Task title cannot be empty.")
98
+ sequence = self.items.next_sequence(self.project.id)
99
+ item = WorkItem(
100
+ uuid4(), self.project.id, sequence,
101
+ f"{self.project.identifier}-{sequence}", title.strip(),
102
+ description=description, type=type_, priority=priority,
103
+ module_id=module.id if module else None,
104
+ cycle_id=cycle.id if cycle else None,
105
+ estimate=estimate,
106
+ status=status or WorkItemStatus.TODO,
107
+ branch_name=branch,
108
+ )
109
+ self.items.add(item)
110
+ self.session.commit()
111
+ return item
112
+
113
+ def update_task(
114
+ self, identifier: str, title=None, description=None, append=None,
115
+ status=None, priority=None, module=None, cycle=None, branch=None, estimate=None
116
+ ):
117
+ item = self.items.get(self.project.id, identifier)
118
+ if not item:
119
+ raise NotFoundError(f"Work Item not found: {identifier}")
120
+ if title is not None:
121
+ item.title = title.strip()
122
+ if description is not None:
123
+ item.description = description
124
+ if append is not None:
125
+ item.description = ((item.description or "").rstrip() + "\n\n" + append.strip()).strip()
126
+ if status is not None:
127
+ item.status = WorkItemStatus(status)
128
+ if priority is not None:
129
+ item.priority = Priority(priority)
130
+ if module is not None:
131
+ item.module_id = module.id
132
+ if cycle is not None:
133
+ item.cycle_id = cycle.id
134
+ if branch is not None:
135
+ item.branch_name = branch
136
+ if estimate is not None:
137
+ item.estimate = estimate
138
+ self.items.update(item)
139
+ self.session.commit()
140
+ return item
141
+
142
+ def add_dependency(self, source: str, target: str):
143
+ source_item = self.items.get(self.project.id, source)
144
+ target_item = self.items.get(self.project.id, target)
145
+ if not source_item or not target_item:
146
+ raise NotFoundError("Both Work Items must exist.")
147
+
148
+ graph = self._dependency_graph()
149
+ graph.add_edge(source_item.identifier, target_item.identifier)
150
+ if not nx.is_directed_acyclic_graph(graph):
151
+ raise DependencyCycleError("Dependency would create a cycle.")
152
+
153
+ self.session.add(WorkItemRelationModel(
154
+ source_work_item_id=str(source_item.id),
155
+ target_work_item_id=str(target_item.id),
156
+ relation_type=RelationType.DEPENDS_ON.value,
157
+ ))
158
+ self.session.commit()
159
+
160
+ def _dependency_graph(self):
161
+ graph = nx.DiGraph()
162
+ rows = self.session.scalars(
163
+ select(WorkItemRelationModel).where(
164
+ WorkItemRelationModel.relation_type == RelationType.DEPENDS_ON.value
165
+ )
166
+ ).all()
167
+ for row in rows:
168
+ source = self.session.get(__import__("nod.infrastructure.models", fromlist=["WorkItemModel"]).WorkItemModel, row.source_work_item_id)
169
+ target = self.session.get(__import__("nod.infrastructure.models", fromlist=["WorkItemModel"]).WorkItemModel, row.target_work_item_id)
170
+ if source and target:
171
+ graph.add_edge(source.identifier, target.identifier)
172
+ return graph
173
+
174
+ def graph(self):
175
+ from nod.infrastructure.models import WorkItemRelationModel, WorkItemModel
176
+ graph = nx.DiGraph()
177
+ items = self.items.list(self.project.id)
178
+ modules = {m.id: m.slug for m in self.modules.list()}
179
+ for item in items:
180
+ graph.add_node(
181
+ item.identifier,
182
+ title=item.title,
183
+ status=item.status.value,
184
+ priority=item.priority.value,
185
+ description=item.description or "",
186
+ branch=item.branch_name,
187
+ module=modules.get(item.module_id),
188
+ )
189
+ rows = self.session.scalars(select(WorkItemRelationModel)).all()
190
+ for row in rows:
191
+ source = self.session.get(WorkItemModel, row.source_work_item_id)
192
+ target = self.session.get(WorkItemModel, row.target_work_item_id)
193
+ if source and target:
194
+ graph.add_edge(source.identifier, target.identifier, relation=row.relation_type)
195
+ return graph
File without changes