attache 0.0.1__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.
- attache-0.0.1/.gitattributes +3 -0
- attache-0.0.1/.github/workflows/ci.yml +67 -0
- attache-0.0.1/.github/workflows/docs.yml +72 -0
- attache-0.0.1/.github/workflows/release.yml +119 -0
- attache-0.0.1/.gitignore +13 -0
- attache-0.0.1/CLAUDE.md +75 -0
- attache-0.0.1/CONTRIBUTING.md +42 -0
- attache-0.0.1/LICENSE +21 -0
- attache-0.0.1/PKG-INFO +109 -0
- attache-0.0.1/README.md +79 -0
- attache-0.0.1/RELEASING.md +68 -0
- attache-0.0.1/docs/assets/openafterhours_icon_512.png +0 -0
- attache-0.0.1/docs/assets/stylesheets/oah-theme.css +167 -0
- attache-0.0.1/docs/configuration.md +354 -0
- attache-0.0.1/docs/design.md +37 -0
- attache-0.0.1/docs/developers/contributing.md +140 -0
- attache-0.0.1/docs/developers/releasing.md +88 -0
- attache-0.0.1/docs/index.md +93 -0
- attache-0.0.1/docs/pages.md +219 -0
- attache-0.0.1/docs/phase-1-ui.png +0 -0
- attache-0.0.1/docs/proposal.html +1263 -0
- attache-0.0.1/docs/quickstart.md +194 -0
- attache-0.0.1/docs/security.md +148 -0
- attache-0.0.1/docs/skills.md +203 -0
- attache-0.0.1/pyproject.toml +72 -0
- attache-0.0.1/src/attache/__init__.py +3 -0
- attache-0.0.1/src/attache/__main__.py +3 -0
- attache-0.0.1/src/attache/agent.py +85 -0
- attache-0.0.1/src/attache/app.py +82 -0
- attache-0.0.1/src/attache/backends/__init__.py +40 -0
- attache-0.0.1/src/attache/backends/anthropic.py +62 -0
- attache-0.0.1/src/attache/backends/claude_cli.py +160 -0
- attache-0.0.1/src/attache/backends/openai.py +101 -0
- attache-0.0.1/src/attache/bundled/attache.toml +56 -0
- attache-0.0.1/src/attache/bundled/memory/ATTACHE.md +11 -0
- attache-0.0.1/src/attache/bundled/memory/USER.md +18 -0
- attache-0.0.1/src/attache/bundled/skills/artifact-kit/SKILL.md +118 -0
- attache-0.0.1/src/attache/bundled/skills/artifact-kit/templates/board.html +315 -0
- attache-0.0.1/src/attache/bundled/skills/artifact-kit/templates/home.html +371 -0
- attache-0.0.1/src/attache/bundled/skills/artifact-kit/tokens.css +176 -0
- attache-0.0.1/src/attache/bundled/skills/reminders/SKILL.md +82 -0
- attache-0.0.1/src/attache/bundled/skills/skill-writer/SKILL.md +91 -0
- attache-0.0.1/src/attache/bundled/skills/todos/SKILL.md +91 -0
- attache-0.0.1/src/attache/channels.py +71 -0
- attache-0.0.1/src/attache/cli.py +126 -0
- attache-0.0.1/src/attache/config.py +160 -0
- attache-0.0.1/src/attache/router.py +248 -0
- attache-0.0.1/src/attache/scheduler.py +88 -0
- attache-0.0.1/src/attache/skills.py +138 -0
- attache-0.0.1/src/attache/static/bridge.js +73 -0
- attache-0.0.1/src/attache/static/fonts/OFL-Inter.txt +92 -0
- attache-0.0.1/src/attache/static/fonts/OFL-JetBrainsMono.txt +93 -0
- attache-0.0.1/src/attache/static/fonts/OFL-SpaceGrotesk.txt +93 -0
- attache-0.0.1/src/attache/static/fonts/inter-latin-400-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts/inter-latin-500-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts/inter-latin-600-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts/inter-latin-700-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts/jetbrains-mono-latin-600-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts/space-grotesk-latin-wght-normal.woff2 +0 -0
- attache-0.0.1/src/attache/static/fonts.css +33 -0
- attache-0.0.1/src/attache/static/oah-tokens.css +45 -0
- attache-0.0.1/src/attache/static/ui.html +363 -0
- attache-0.0.1/src/attache/store.py +234 -0
- attache-0.0.1/src/attache/tools.py +258 -0
- attache-0.0.1/src/attache/types.py +133 -0
- attache-0.0.1/src/attache/web.py +314 -0
- attache-0.0.1/tests/conftest.py +14 -0
- attache-0.0.1/tests/fixtures/claude_stream.jsonl +6 -0
- attache-0.0.1/tests/test_agent.py +98 -0
- attache-0.0.1/tests/test_app.py +111 -0
- attache-0.0.1/tests/test_backends.py +325 -0
- attache-0.0.1/tests/test_budget.py +53 -0
- attache-0.0.1/tests/test_bundled_skills.py +100 -0
- attache-0.0.1/tests/test_channels.py +101 -0
- attache-0.0.1/tests/test_claude_cli.py +352 -0
- attache-0.0.1/tests/test_cli.py +160 -0
- attache-0.0.1/tests/test_config.py +226 -0
- attache-0.0.1/tests/test_router.py +436 -0
- attache-0.0.1/tests/test_scheduler.py +218 -0
- attache-0.0.1/tests/test_skills.py +205 -0
- attache-0.0.1/tests/test_store.py +232 -0
- attache-0.0.1/tests/test_tools.py +349 -0
- attache-0.0.1/tests/test_web.py +657 -0
- attache-0.0.1/uv.lock +984 -0
- attache-0.0.1/zensical.toml +139 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
# release.yml calls this, so a tag is gated by exactly the checks main is gated by.
|
|
9
|
+
workflow_call:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
# A newer push makes an older run's answer irrelevant. Tags are exempt: cancelling a release
|
|
15
|
+
# mid-flight is never what anyone wants.
|
|
16
|
+
concurrency:
|
|
17
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
18
|
+
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
19
|
+
|
|
20
|
+
env:
|
|
21
|
+
UV_LOCKED: "1" # a stale uv.lock fails CI instead of being re-resolved
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
lint:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
timeout-minutes: 10
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v7
|
|
29
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
30
|
+
# Pinned to the locked version, so a new ruff release can't fail unrelated pull requests.
|
|
31
|
+
- name: Ruff lint
|
|
32
|
+
run: uvx ruff@0.17.1 check .
|
|
33
|
+
- name: Ruff format check
|
|
34
|
+
run: uvx ruff@0.17.1 format --check .
|
|
35
|
+
|
|
36
|
+
test:
|
|
37
|
+
runs-on: ${{ matrix.os }}
|
|
38
|
+
timeout-minutes: 20
|
|
39
|
+
strategy:
|
|
40
|
+
fail-fast: false
|
|
41
|
+
matrix:
|
|
42
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
43
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
47
|
+
with:
|
|
48
|
+
python-version: ${{ matrix.python-version }}
|
|
49
|
+
- name: Run tests (includes the core size budget)
|
|
50
|
+
run: uv run pytest
|
|
51
|
+
|
|
52
|
+
build:
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
timeout-minutes: 10
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v7
|
|
57
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
58
|
+
- name: Build sdist and wheel
|
|
59
|
+
run: uv build
|
|
60
|
+
- name: Smoke-test the built wheel
|
|
61
|
+
run: |
|
|
62
|
+
wheel=$(ls dist/*.whl)
|
|
63
|
+
uvx --isolated --from "$wheel" attache --help
|
|
64
|
+
uvx --isolated --from "$wheel" attache init --home "$RUNNER_TEMP/home" --name CI
|
|
65
|
+
test -f "$RUNNER_TEMP/home/skills/todos/SKILL.md"
|
|
66
|
+
test -f "$RUNNER_TEMP/home/artifacts/home.html"
|
|
67
|
+
uv run --isolated --no-project --with "$wheel" python -c "import attache.app, attache.web"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Docs
|
|
2
|
+
|
|
3
|
+
# Build the Zensical documentation site and publish it to GitHub Pages.
|
|
4
|
+
# Pull requests build it too, without deploying, so a broken page shows up in review.
|
|
5
|
+
#
|
|
6
|
+
# One-time setup: Settings -> Pages -> Build and deployment -> Source = GitHub Actions.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches: [main]
|
|
10
|
+
paths:
|
|
11
|
+
- "docs/**"
|
|
12
|
+
- "zensical.toml"
|
|
13
|
+
- "pyproject.toml"
|
|
14
|
+
- "uv.lock"
|
|
15
|
+
- ".github/workflows/docs.yml"
|
|
16
|
+
pull_request:
|
|
17
|
+
paths:
|
|
18
|
+
- "docs/**"
|
|
19
|
+
- "zensical.toml"
|
|
20
|
+
- "pyproject.toml"
|
|
21
|
+
- "uv.lock"
|
|
22
|
+
- ".github/workflows/docs.yml"
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
|
|
25
|
+
# Least privilege: build only reads the repo; deploy alone gets the Pages and OIDC scopes.
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
# One Pages deploy at a time, and never cancel one halfway. Pull request builds get their own
|
|
30
|
+
# group, so they neither wait for nor cancel a deploy.
|
|
31
|
+
concurrency:
|
|
32
|
+
group: ${{ github.event_name == 'pull_request' && format('docs-{0}', github.ref) || 'pages' }}
|
|
33
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
34
|
+
|
|
35
|
+
env:
|
|
36
|
+
UV_LOCKED: "1" # a stale uv.lock fails the build instead of being re-resolved
|
|
37
|
+
|
|
38
|
+
# Actions are pinned to commit SHAs: the deploy job can write to the Pages site.
|
|
39
|
+
jobs:
|
|
40
|
+
build:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
timeout-minutes: 10
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
45
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
46
|
+
- name: Configure Pages
|
|
47
|
+
if: github.event_name != 'pull_request'
|
|
48
|
+
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
|
49
|
+
# --strict fails the build on a link to a missing page or anchor.
|
|
50
|
+
- name: Build the docs site
|
|
51
|
+
run: uv run --only-group docs zensical build --clean --strict
|
|
52
|
+
- name: Upload Pages artifact
|
|
53
|
+
if: github.event_name != 'pull_request'
|
|
54
|
+
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
|
55
|
+
with:
|
|
56
|
+
path: site
|
|
57
|
+
|
|
58
|
+
deploy:
|
|
59
|
+
if: github.event_name != 'pull_request'
|
|
60
|
+
needs: build
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
timeout-minutes: 10
|
|
63
|
+
permissions:
|
|
64
|
+
pages: write
|
|
65
|
+
id-token: write
|
|
66
|
+
environment:
|
|
67
|
+
name: github-pages
|
|
68
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
69
|
+
steps:
|
|
70
|
+
- name: Deploy to GitHub Pages
|
|
71
|
+
id: deployment
|
|
72
|
+
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Cutting a release is: bump `__version__` in src/attache/__init__.py, merge, tag `vX.Y.Z` on
|
|
4
|
+
# main, push the tag. Everything below happens on the tag. See RELEASING.md.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
# Actions are pinned to commit SHAs: this workflow can publish to PyPI and write releases.
|
|
13
|
+
jobs:
|
|
14
|
+
verify:
|
|
15
|
+
name: verify tag matches package version
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
timeout-minutes: 5
|
|
18
|
+
outputs:
|
|
19
|
+
version: ${{ steps.version.outputs.version }}
|
|
20
|
+
prerelease: ${{ steps.version.outputs.prerelease }}
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
23
|
+
# First and dependency-free: a tag that disagrees with __version__ should cost seconds,
|
|
24
|
+
# not a full test matrix.
|
|
25
|
+
- id: version
|
|
26
|
+
shell: python
|
|
27
|
+
run: |
|
|
28
|
+
import ast, os, re, sys
|
|
29
|
+
tree = ast.parse(open("src/attache/__init__.py", encoding="utf-8").read())
|
|
30
|
+
version = next(
|
|
31
|
+
node.value.value
|
|
32
|
+
for node in tree.body
|
|
33
|
+
if isinstance(node, ast.Assign) and node.targets[0].id == "__version__"
|
|
34
|
+
)
|
|
35
|
+
tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
|
|
36
|
+
if tag != version:
|
|
37
|
+
sys.exit(f"tag v{tag} does not match __version__ {version}")
|
|
38
|
+
prerelease = bool(re.search(r"(a|b|rc|dev)\d*$", version))
|
|
39
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out:
|
|
40
|
+
out.write(f"version={version}\nprerelease={str(prerelease).lower()}\n")
|
|
41
|
+
|
|
42
|
+
ci:
|
|
43
|
+
name: ci
|
|
44
|
+
needs: verify
|
|
45
|
+
uses: ./.github/workflows/ci.yml
|
|
46
|
+
|
|
47
|
+
build:
|
|
48
|
+
name: build release artifacts
|
|
49
|
+
needs: [verify, ci]
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
timeout-minutes: 10
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
54
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
55
|
+
- run: uv build
|
|
56
|
+
- name: Check the metadata PyPI will render
|
|
57
|
+
run: uvx twine check --strict dist/*
|
|
58
|
+
# The wheel filename is what gets uploaded, so assert on it rather than trusting that the
|
|
59
|
+
# build backend read the same version the verify job did.
|
|
60
|
+
- name: Confirm the artifacts carry the tagged version
|
|
61
|
+
env:
|
|
62
|
+
VERSION: ${{ needs.verify.outputs.version }}
|
|
63
|
+
run: |
|
|
64
|
+
set -euo pipefail
|
|
65
|
+
test -f "dist/attache-${VERSION}-py3-none-any.whl" || { ls -1 dist; exit 1; }
|
|
66
|
+
test -f "dist/attache-${VERSION}.tar.gz" || { ls -1 dist; exit 1; }
|
|
67
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
|
68
|
+
with:
|
|
69
|
+
name: dist
|
|
70
|
+
path: dist/
|
|
71
|
+
retention-days: 30
|
|
72
|
+
|
|
73
|
+
publish:
|
|
74
|
+
name: publish to PyPI
|
|
75
|
+
needs: [verify, build]
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
timeout-minutes: 10
|
|
78
|
+
# Trusted Publishing: PyPI accepts uploads from this repository, this workflow file and this
|
|
79
|
+
# environment, with no API token stored anywhere. The environment is also where a manual
|
|
80
|
+
# approval gate goes, should you ever want one before an upload.
|
|
81
|
+
environment:
|
|
82
|
+
name: pypi
|
|
83
|
+
url: https://pypi.org/p/attache
|
|
84
|
+
permissions:
|
|
85
|
+
id-token: write # mints the short-lived OIDC token PyPI exchanges for an upload token
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
88
|
+
with:
|
|
89
|
+
name: dist
|
|
90
|
+
path: dist/
|
|
91
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
92
|
+
|
|
93
|
+
github-release:
|
|
94
|
+
name: create the GitHub release
|
|
95
|
+
needs: [verify, publish]
|
|
96
|
+
runs-on: ubuntu-latest
|
|
97
|
+
timeout-minutes: 10
|
|
98
|
+
permissions:
|
|
99
|
+
contents: write
|
|
100
|
+
steps:
|
|
101
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
102
|
+
with:
|
|
103
|
+
fetch-depth: 0 # --generate-notes diffs against the previous tag
|
|
104
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
105
|
+
with:
|
|
106
|
+
name: dist
|
|
107
|
+
path: dist/
|
|
108
|
+
# Last, so a release never claims success for an upload that failed.
|
|
109
|
+
- name: gh release create
|
|
110
|
+
env:
|
|
111
|
+
GH_TOKEN: ${{ github.token }}
|
|
112
|
+
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
|
113
|
+
run: |
|
|
114
|
+
set -euo pipefail
|
|
115
|
+
flags=(--generate-notes --verify-tag --title "${GITHUB_REF_NAME}")
|
|
116
|
+
if [ "${PRERELEASE}" = "true" ]; then
|
|
117
|
+
flags+=(--prerelease)
|
|
118
|
+
fi
|
|
119
|
+
gh release create "${GITHUB_REF_NAME}" "${flags[@]}" dist/*
|
attache-0.0.1/.gitignore
ADDED
attache-0.0.1/CLAUDE.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Attaché: notes for agents and contributors
|
|
2
|
+
|
|
3
|
+
Attaché is a personal assistant packaged as a Python tool (`uvx attache`). The core is small on
|
|
4
|
+
purpose. Channels, email, file formats and integrations are **skills**, not core code. The
|
|
5
|
+
design is in `docs/proposal.html`; read §3 (architecture) and §10 (code budget) first.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
uv sync # install dev environment
|
|
11
|
+
uv run pytest # all tests, including the size budget
|
|
12
|
+
uv run ruff check . && uv run ruff format --check .
|
|
13
|
+
uv run attache --help
|
|
14
|
+
uv run --group docs zensical serve # docs site (zensical.toml, docs/); build with --strict
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The web UI, the bundled page kit and the docs site all use the OpenAfterHours design tokens
|
|
18
|
+
(`src/attache/static/oah-tokens.css`, vendored from rwa_calculator; a test guards drift). Fonts
|
|
19
|
+
are self-hosted in `src/attache/static/fonts/` (SIL OFL), so nothing loads from the internet.
|
|
20
|
+
|
|
21
|
+
## Layout
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
src/attache/
|
|
25
|
+
types.py shared dataclasses and protocols; every module talks through these
|
|
26
|
+
app.py builds Services and runs everything
|
|
27
|
+
config.py attache.toml -> Config; init_home()
|
|
28
|
+
store.py SQLite: messages, docs, tasks, sessions
|
|
29
|
+
skills.py skill discovery, prompt index, git commit/log/undo of <home>
|
|
30
|
+
scheduler.py fires due tasks
|
|
31
|
+
agent.py shared tool loop for API backends
|
|
32
|
+
backends/ anthropic, openai (chat + responses), claude_cli
|
|
33
|
+
tools.py the tools a turn may use, filtered by role
|
|
34
|
+
router.py inbound message -> access, trigger, role -> turn -> reply
|
|
35
|
+
channels.py channel manager, terminal channel
|
|
36
|
+
web.py Starlette app: chat page, artifacts, data bridge, SSE, MCP endpoint for
|
|
37
|
+
Claude Code (POST /mcp/{token})
|
|
38
|
+
cli.py argparse entry point
|
|
39
|
+
static/ ui.html, bridge.js
|
|
40
|
+
bundled/ attache.toml template, memory templates, bundled skills (copied by init)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Rules
|
|
44
|
+
|
|
45
|
+
1. **Size is a test.** `tests/test_budget.py` fails above 1,600 lines of code in
|
|
46
|
+
`src/attache` (target about 1,500). Blank lines, comments and docstrings don't count, so
|
|
47
|
+
never make code denser to fit: write the readable version. Bundled skills are excluded;
|
|
48
|
+
each bundled skill's scripts have a 300-line cap. If a feature doesn't fit, it belongs in a
|
|
49
|
+
skill.
|
|
50
|
+
2. **No new runtime dependencies** without discussion. Skill scripts declare their own with
|
|
51
|
+
PEP 723 headers and run through `uv run`.
|
|
52
|
+
3. **Tests run offline.** No network, no API keys, no real `claude` binary. Inject fakes
|
|
53
|
+
(every backend takes a `client`/`runner` argument for this).
|
|
54
|
+
4. **Cross-platform.** Windows, macOS and Linux. Use pathlib, `encoding="utf-8"` on every text
|
|
55
|
+
read and write, argument lists for subprocesses (no shell unless the `run` tool), and no
|
|
56
|
+
POSIX-only APIs.
|
|
57
|
+
5. **Style.** ruff with the repo config, `from __future__ import annotations`, type hints on
|
|
58
|
+
public functions, short docstrings on public functions only, comments only for a
|
|
59
|
+
non-obvious why. Plain functions and dataclasses over class hierarchies.
|
|
60
|
+
6. **Security model.**
|
|
61
|
+
- Roles are owner, member and guest (router.py). Only the owner's own chats get file, shell
|
|
62
|
+
and memory tools; an owner turn inside a group gets member tools, because group context is
|
|
63
|
+
untrusted. Non-owner turns never use the `claude-cli` backend (Anthropic's terms).
|
|
64
|
+
- Members act only on docs shared with their chat. Other people's messages reach the model
|
|
65
|
+
fenced and labelled as data; display names are never trusted.
|
|
66
|
+
- Text from tools, emails and web pages is untrusted data, never instructions.
|
|
67
|
+
- Artifact pages are hostile HTML: served with a CSP sandbox (opaque origin, no outbound
|
|
68
|
+
requests) and only inside the Attaché window. The owner's UI token is random per run and
|
|
69
|
+
only reaches the browser through the link Attaché prints.
|
|
70
|
+
- The web server binds loopback only in phase 1. Tokens are compared with
|
|
71
|
+
`hmac.compare_digest`; page tokens derive from the per-run UI token, so they rotate too.
|
|
72
|
+
- `claude -p` runs in `<home>-claude`, outside the home, so files in `<home>` never load as
|
|
73
|
+
Claude Code project config.
|
|
74
|
+
7. **Contracts live in `types.py`** and the module docstrings. Change a public signature only
|
|
75
|
+
together with every caller and its tests.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Attaché stays small so anyone can read the core in an afternoon and change it with confidence.
|
|
4
|
+
|
|
5
|
+
**Accepted in the core:** bug fixes, security fixes, simplifications, less code.
|
|
6
|
+
|
|
7
|
+
**Not accepted in the core:** features, integrations, channels, file formats. These should be
|
|
8
|
+
skills. (This rule comes from [nanoclaw](https://github.com/nanocoai/nanoclaw).)
|
|
9
|
+
|
|
10
|
+
## Adding a skill
|
|
11
|
+
|
|
12
|
+
A skill is a folder with a `SKILL.md` in the [Agent Skills](https://agentskills.io/specification)
|
|
13
|
+
format:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
skills/<name>/
|
|
17
|
+
SKILL.md frontmatter: name, description, optional metadata
|
|
18
|
+
scripts/*.py optional; PEP 723 header declares dependencies, run with `uv run`
|
|
19
|
+
templates/ optional; artifact templates
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Metadata keys Attaché reads:
|
|
23
|
+
|
|
24
|
+
- `attache-roles: "member"` makes the skill available in group chats: members see its full
|
|
25
|
+
text in their prompt and may run its scripts. Without it a skill is owner-only.
|
|
26
|
+
- `attache-channel: "<command>"` marks a channel sidecar (phase 2).
|
|
27
|
+
|
|
28
|
+
Bundled skills live in `src/attache/bundled/skills/` and are copied into a user's home by
|
|
29
|
+
`attache init`. Each bundled skill's scripts are capped at 300 lines of code by
|
|
30
|
+
`tests/test_budget.py`.
|
|
31
|
+
|
|
32
|
+
## The size budget
|
|
33
|
+
|
|
34
|
+
`tests/test_budget.py` fails when the core passes 1,600 lines of code. Blank lines, comments
|
|
35
|
+
and docstrings don't count, so write the clearest version of the code, not the shortest.
|
|
36
|
+
|
|
37
|
+
## Before you open a pull request
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
uv run ruff check . && uv run ruff format --check .
|
|
41
|
+
uv run pytest
|
|
42
|
+
```
|
attache-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenAfterHours contributors
|
|
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.
|
attache-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: attache
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A personal assistant you install with one command. Small core; channels, email and file formats are skills.
|
|
5
|
+
Project-URL: Homepage, https://github.com/OpenAfterHours/attache
|
|
6
|
+
Project-URL: Repository, https://github.com/OpenAfterHours/attache
|
|
7
|
+
Project-URL: Issues, https://github.com/OpenAfterHours/attache/issues
|
|
8
|
+
Author: OpenAfterHours contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,assistant,claude,local-first,openai,skills
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: anthropic>=0.40
|
|
24
|
+
Requires-Dist: cronsim>=2.6
|
|
25
|
+
Requires-Dist: openai>=1.66
|
|
26
|
+
Requires-Dist: pyyaml>=6
|
|
27
|
+
Requires-Dist: starlette>=0.40
|
|
28
|
+
Requires-Dist: uvicorn>=0.30
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Attaché
|
|
32
|
+
|
|
33
|
+
A personal assistant you install with one command. It runs on your own machine, keeps your
|
|
34
|
+
to-dos and reminders, makes interactive pages on demand, and learns how you work through a
|
|
35
|
+
folder of skills it can edit itself. The core is just under 1,600 lines of Python code, the
|
|
36
|
+
cap CI enforces; everything else is a skill.
|
|
37
|
+
|
|
38
|
+
> **Status:** phase 1 of the [roadmap](docs/proposal.html): the local core, terminal and web
|
|
39
|
+
> chat. Slack and Signal arrive in phase 2, email (Outlook, Gmail, Titan) in phase 3.
|
|
40
|
+
|
|
41
|
+
**Documentation:** <https://openafterhours.github.io/attache/>.
|
|
42
|
+
|
|
43
|
+
## Try it
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
uvx attache # first run asks your name, creates ~/.attache, then opens a chat
|
|
47
|
+
uv tool install attache # keep it
|
|
48
|
+
attache chat # terminal chat, plus the web page
|
|
49
|
+
attache run # web page, reminders and channels, no terminal chat
|
|
50
|
+
attache doctor # check your setup
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
The terminal and the web page are one conversation. Attaché prints a link like
|
|
56
|
+
`http://127.0.0.1:8765/#t=...` when it starts; open that link to use the web page. The token in
|
|
57
|
+
it changes every run, so other programs on your machine can't drive your assistant.
|
|
58
|
+
|
|
59
|
+
Attaché talks to models through four adapters, chosen in `~/.attache/attache.toml`:
|
|
60
|
+
|
|
61
|
+
| `api` | Use it for |
|
|
62
|
+
|--------------------|-------------------------------------------------------------------|
|
|
63
|
+
| `claude-cli` | Claude Code (`claude -p`) on your own login. Your own chats only. |
|
|
64
|
+
| `anthropic` | The Claude API, or any Anthropic-compatible `base_url` |
|
|
65
|
+
| `openai-chat` | Ollama, LM Studio, vLLM, llama.cpp, OpenRouter, Groq, Azure |
|
|
66
|
+
| `openai-responses` | OpenAI reasoning models with tools |
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
[routing]
|
|
70
|
+
own_chats = "claude" # your Claude login
|
|
71
|
+
group_chats = "claude-api" # an API key, per Anthropic's terms
|
|
72
|
+
|
|
73
|
+
[endpoints.claude]
|
|
74
|
+
api = "claude-cli"
|
|
75
|
+
model = "claude-sonnet-5-5"
|
|
76
|
+
|
|
77
|
+
[endpoints.claude-api]
|
|
78
|
+
api = "anthropic"
|
|
79
|
+
model = "claude-sonnet-5-5"
|
|
80
|
+
key_env = "ANTHROPIC_API_KEY"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How it is put together
|
|
84
|
+
|
|
85
|
+
- **Skills** live in `~/.attache/skills/<name>/SKILL.md` ([Agent Skills](https://agentskills.io)
|
|
86
|
+
format). Only names and descriptions go into the prompt. The assistant edits skills when you
|
|
87
|
+
change how you work, and `~/.attache` is a git repo, so `attache skills log` and
|
|
88
|
+
`attache skills undo <name>` show and reverse every change.
|
|
89
|
+
- **Artifacts** are HTML pages the assistant writes into `~/.attache/artifacts/`. The local web
|
|
90
|
+
server adds a small `attache` bridge so a page can read and write real data with live updates.
|
|
91
|
+
Pages run sandboxed: they can't reach the internet or anything outside the data they declare.
|
|
92
|
+
- **Roles.** You are the owner. Group members (from phase 2) only reach what is shared with
|
|
93
|
+
their chat, and never files, shell or email. Your own turns inside a group get member tools
|
|
94
|
+
too, because the group's messages are untrusted.
|
|
95
|
+
- **Claude Code** runs as a backend with `claude -p` on your own login, for your own chats only.
|
|
96
|
+
Attaché's tools reach it through an MCP endpoint on the local web server. It runs in a
|
|
97
|
+
working folder next to your home (`~/.attache-claude`), so nothing written into `~/.attache`
|
|
98
|
+
can load as Claude Code project settings.
|
|
99
|
+
|
|
100
|
+
The full design, with the research behind it, is in [docs/proposal.html](docs/proposal.html).
|
|
101
|
+
|
|
102
|
+
## Contributing
|
|
103
|
+
|
|
104
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md): bug fixes and simplifications go in the core, features
|
|
105
|
+
go in skills.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
attache-0.0.1/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Attaché
|
|
2
|
+
|
|
3
|
+
A personal assistant you install with one command. It runs on your own machine, keeps your
|
|
4
|
+
to-dos and reminders, makes interactive pages on demand, and learns how you work through a
|
|
5
|
+
folder of skills it can edit itself. The core is just under 1,600 lines of Python code, the
|
|
6
|
+
cap CI enforces; everything else is a skill.
|
|
7
|
+
|
|
8
|
+
> **Status:** phase 1 of the [roadmap](docs/proposal.html): the local core, terminal and web
|
|
9
|
+
> chat. Slack and Signal arrive in phase 2, email (Outlook, Gmail, Titan) in phase 3.
|
|
10
|
+
|
|
11
|
+
**Documentation:** <https://openafterhours.github.io/attache/>.
|
|
12
|
+
|
|
13
|
+
## Try it
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
uvx attache # first run asks your name, creates ~/.attache, then opens a chat
|
|
17
|
+
uv tool install attache # keep it
|
|
18
|
+
attache chat # terminal chat, plus the web page
|
|
19
|
+
attache run # web page, reminders and channels, no terminal chat
|
|
20
|
+
attache doctor # check your setup
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
The terminal and the web page are one conversation. Attaché prints a link like
|
|
26
|
+
`http://127.0.0.1:8765/#t=...` when it starts; open that link to use the web page. The token in
|
|
27
|
+
it changes every run, so other programs on your machine can't drive your assistant.
|
|
28
|
+
|
|
29
|
+
Attaché talks to models through four adapters, chosen in `~/.attache/attache.toml`:
|
|
30
|
+
|
|
31
|
+
| `api` | Use it for |
|
|
32
|
+
|--------------------|-------------------------------------------------------------------|
|
|
33
|
+
| `claude-cli` | Claude Code (`claude -p`) on your own login. Your own chats only. |
|
|
34
|
+
| `anthropic` | The Claude API, or any Anthropic-compatible `base_url` |
|
|
35
|
+
| `openai-chat` | Ollama, LM Studio, vLLM, llama.cpp, OpenRouter, Groq, Azure |
|
|
36
|
+
| `openai-responses` | OpenAI reasoning models with tools |
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
[routing]
|
|
40
|
+
own_chats = "claude" # your Claude login
|
|
41
|
+
group_chats = "claude-api" # an API key, per Anthropic's terms
|
|
42
|
+
|
|
43
|
+
[endpoints.claude]
|
|
44
|
+
api = "claude-cli"
|
|
45
|
+
model = "claude-sonnet-5-5"
|
|
46
|
+
|
|
47
|
+
[endpoints.claude-api]
|
|
48
|
+
api = "anthropic"
|
|
49
|
+
model = "claude-sonnet-5-5"
|
|
50
|
+
key_env = "ANTHROPIC_API_KEY"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How it is put together
|
|
54
|
+
|
|
55
|
+
- **Skills** live in `~/.attache/skills/<name>/SKILL.md` ([Agent Skills](https://agentskills.io)
|
|
56
|
+
format). Only names and descriptions go into the prompt. The assistant edits skills when you
|
|
57
|
+
change how you work, and `~/.attache` is a git repo, so `attache skills log` and
|
|
58
|
+
`attache skills undo <name>` show and reverse every change.
|
|
59
|
+
- **Artifacts** are HTML pages the assistant writes into `~/.attache/artifacts/`. The local web
|
|
60
|
+
server adds a small `attache` bridge so a page can read and write real data with live updates.
|
|
61
|
+
Pages run sandboxed: they can't reach the internet or anything outside the data they declare.
|
|
62
|
+
- **Roles.** You are the owner. Group members (from phase 2) only reach what is shared with
|
|
63
|
+
their chat, and never files, shell or email. Your own turns inside a group get member tools
|
|
64
|
+
too, because the group's messages are untrusted.
|
|
65
|
+
- **Claude Code** runs as a backend with `claude -p` on your own login, for your own chats only.
|
|
66
|
+
Attaché's tools reach it through an MCP endpoint on the local web server. It runs in a
|
|
67
|
+
working folder next to your home (`~/.attache-claude`), so nothing written into `~/.attache`
|
|
68
|
+
can load as Claude Code project settings.
|
|
69
|
+
|
|
70
|
+
The full design, with the research behind it, is in [docs/proposal.html](docs/proposal.html).
|
|
71
|
+
|
|
72
|
+
## Contributing
|
|
73
|
+
|
|
74
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md): bug fixes and simplifications go in the core, features
|
|
75
|
+
go in skills.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Releasing attache
|
|
2
|
+
|
|
3
|
+
A release is a pushed tag. The tag check, the full CI matrix, the build, the PyPI upload and the
|
|
4
|
+
GitHub release all happen in `.github/workflows/release.yml`.
|
|
5
|
+
|
|
6
|
+
## One-time setup: PyPI Trusted Publishing
|
|
7
|
+
|
|
8
|
+
attache uploads to PyPI over OIDC. There is no API token in this repository and there should
|
|
9
|
+
never be one. PyPI needs to be told, once, which workflow it accepts uploads from.
|
|
10
|
+
|
|
11
|
+
`attache` has not been published yet, so this is a **pending publisher**. It becomes an ordinary
|
|
12
|
+
publisher the moment the first release lands, and that release also claims the name.
|
|
13
|
+
|
|
14
|
+
1. Sign in at <https://pypi.org> and open **Account settings → Publishing**.
|
|
15
|
+
2. Under *Add a new pending publisher*, choose **GitHub** and fill in exactly:
|
|
16
|
+
|
|
17
|
+
| Field | Value |
|
|
18
|
+
|---|---|
|
|
19
|
+
| PyPI project name | `attache` |
|
|
20
|
+
| Owner | `OpenAfterHours` |
|
|
21
|
+
| Repository name | `attache` |
|
|
22
|
+
| Workflow name | `release.yml` |
|
|
23
|
+
| Environment name | `pypi` |
|
|
24
|
+
|
|
25
|
+
3. Save. All five fields must match or the upload is rejected. The environment name in
|
|
26
|
+
particular is easy to leave blank, and it is part of the claim.
|
|
27
|
+
|
|
28
|
+
### The `pypi` environment
|
|
29
|
+
|
|
30
|
+
The `publish` job runs in a GitHub environment named `pypi`; GitHub creates it on the first
|
|
31
|
+
release. It pins the Trusted Publishing claim, and it is where an approval gate goes:
|
|
32
|
+
|
|
33
|
+
- **Settings → Environments → pypi → Required reviewers** makes a person approve each upload.
|
|
34
|
+
Leave it unprotected and a pushed tag publishes on its own, which is the intended default.
|
|
35
|
+
- A tag ruleset (**Settings → Rules → Rulesets**, target `v*`) limits who can create release
|
|
36
|
+
tags. Without one, anyone with write access can publish by pushing a tag.
|
|
37
|
+
|
|
38
|
+
## Cutting a release
|
|
39
|
+
|
|
40
|
+
Versions follow PEP 440. The single source of truth is `__version__` in
|
|
41
|
+
`src/attache/__init__.py`; `pyproject.toml` reads it.
|
|
42
|
+
|
|
43
|
+
1. Bump `__version__` in a pull request and merge it once CI is green.
|
|
44
|
+
2. Tag the merge commit on `main` and push the tag:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git checkout main
|
|
48
|
+
git pull
|
|
49
|
+
git tag v0.0.1
|
|
50
|
+
git push origin v0.0.1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
3. Watch **Actions → Release**. In order, it:
|
|
54
|
+
- checks the tag matches `__version__`;
|
|
55
|
+
- runs the whole CI workflow (lint, tests on Ubuntu, Windows and macOS, the wheel smoke test);
|
|
56
|
+
- builds the sdist and wheel and checks their metadata with `twine check --strict`;
|
|
57
|
+
- uploads them to PyPI;
|
|
58
|
+
- creates the GitHub release with generated notes and both files attached.
|
|
59
|
+
|
|
60
|
+
A tag that doesn't match `__version__` fails in the first job, before anything is built. Versions
|
|
61
|
+
ending in `a`, `b`, `rc` or `dev` (for example `0.1.0rc1`) are marked as pre-releases on GitHub.
|
|
62
|
+
|
|
63
|
+
## If a release fails
|
|
64
|
+
|
|
65
|
+
- **Before the publish job:** fix the problem on `main`, delete the tag
|
|
66
|
+
(`git push --delete origin v0.0.1` and `git tag -d v0.0.1`), and tag again.
|
|
67
|
+
- **After the upload:** PyPI never accepts the same version twice. Bump to the next version and
|
|
68
|
+
release that.
|
|
Binary file
|